From bb5e700e1449d8257a056c1a9aa471a72bc34845 Mon Sep 17 00:00:00 2001
From: aallocca <16933662+BrickBios@users.noreply.github.com>
Date: Sun, 24 May 2026 11:39:05 +0200
Subject: [PATCH] fix gamescreen fix appscreen
---
app.json | 21 ++-
app/module/[id].tsx | 16 +-
package-lock.json | 128 +++++++++++----
package.json | 7 +-
src/ai/aiClient.ts | 13 +-
src/ai/builtinGameTemplates.ts | 3 +-
src/ai/modulePrompt.ts | 55 +++++--
src/ai/normalizeGeneratedModule.ts | 20 +++
src/capabilities/audioPlayer.ts | 44 +++---
src/capabilities/audioRecorder.ts | 34 ++--
src/capabilities/fileStorage.ts | 16 ++
src/capabilities/types.ts | 2 +-
src/modules/moduleValidator.ts | 34 ++--
src/renderer/CameraCaptureModal.tsx | 114 ++++++--------
src/renderer/DynamicRenderer.tsx | 147 +++++++++++++++---
src/renderer/components.tsx | 233 ++++++++++++++++++++++++++--
src/security/runtimePermissions.ts | 4 +-
src/types/generatedModule.ts | 11 ++
src/types/uiNodes.ts | 15 ++
19 files changed, 717 insertions(+), 200 deletions(-)
diff --git a/app.json b/app.json
index b6ffd44..7e44b06 100644
--- a/app.json
+++ b/app.json
@@ -70,19 +70,28 @@
}
],
[
- "expo-av",
+ "expo-notifications",
{
- "microphonePermission": "Allow AppFromAI to record audio through enabled modules."
+ "icon": "./assets/icon.png",
+ "color": "#111827"
}
],
+ "expo-web-browser",
[
- "expo-notifications",
+ "expo-audio",
{
- "icon": "./assets/icon.png",
- "color": "#111827"
+ "microphonePermission": "Allow AppFromAI to record audio through enabled modules."
}
],
- "expo-web-browser"
+ "expo-asset",
+ [
+ "expo-image-picker",
+ {
+ "cameraPermission": "Allow AppFromAI to open the camera for photo modules.",
+ "photosPermission": "Allow AppFromAI to use photos selected by enabled modules.",
+ "microphonePermission": false
+ }
+ ]
],
"extra": {
"router": {}
diff --git a/app/module/[id].tsx b/app/module/[id].tsx
index 0cca7a4..5210714 100644
--- a/app/module/[id].tsx
+++ b/app/module/[id].tsx
@@ -116,6 +116,15 @@ export default function ModuleScreen() {
});
}, [mod, grantedIds, registry]);
+ const webGameAllowNetwork = useMemo(() => {
+ if (!mod) return false;
+ return (
+ isModuleNetworkFetchEnabled() &&
+ mod.manifest.permissions.includes('network') &&
+ grantedIds.includes('network')
+ );
+ }, [mod, grantedIds]);
+
// ── Regen handler ─────────────────────────────────────────────────────────
const onRegenConfirm = async () => {
if (!mod || !id) return;
@@ -258,7 +267,12 @@ export default function ModuleScreen() {
/>
) : motherApi ? (
-
+
) : null}
diff --git a/package-lock.json b/package-lock.json
index adafcbd..c95863b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,12 +14,15 @@
"axios": "^1.15.2",
"babel-preset-expo": "~54.0.10",
"expo": "~54.0.33",
- "expo-av": "~16.0.8",
+ "expo-asset": "~12.0.13",
+ "expo-audio": "~1.1.1",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13",
+ "expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.22",
"expo-haptics": "~15.0.8",
+ "expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.11",
"expo-location": "~19.0.8",
"expo-notifications": "~0.32.16",
@@ -29,6 +32,7 @@
"expo-speech": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-system-ui": "~6.0.9",
+ "expo-web-browser": "~15.0.11",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-native": "0.81.5",
@@ -36,6 +40,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
+ "react-native-webview": "13.15.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -2302,12 +2307,12 @@
"license": "MIT"
},
"node_modules/@expo/spawn-async": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz",
- "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==",
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz",
+ "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==",
"license": "MIT",
"dependencies": {
- "cross-spawn": "^7.0.3"
+ "cross-spawn": "^7.0.6"
},
"engines": {
"node": ">=12"
@@ -5793,21 +5798,31 @@
"expo": "*"
}
},
- "node_modules/expo-av": {
- "version": "16.0.8",
- "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz",
- "integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==",
+ "node_modules/expo-asset": {
+ "version": "12.0.13",
+ "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
+ "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==",
"license": "MIT",
+ "dependencies": {
+ "@expo/image-utils": "^0.8.8",
+ "expo-constants": "~18.0.13"
+ },
"peerDependencies": {
"expo": "*",
"react": "*",
- "react-native": "*",
- "react-native-web": "*"
- },
- "peerDependenciesMeta": {
- "react-native-web": {
- "optional": true
- }
+ "react-native": "*"
+ }
+ },
+ "node_modules/expo-audio": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-1.1.1.tgz",
+ "integrity": "sha512-CPCpJ+0AEHdzWROc0f00Zh6e+irLSl2ALos/LPvxEeIcJw1APfBa4DuHPkL4CQCWsVe7EnUjFpdwpqsEUWcP0g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "expo-asset": "*",
+ "react": "*",
+ "react-native": "*"
}
},
"node_modules/expo-camera": {
@@ -5855,6 +5870,15 @@
"react-native": "*"
}
},
+ "node_modules/expo-document-picker": {
+ "version": "14.0.8",
+ "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
+ "integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-file-system": {
"version": "19.0.22",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
@@ -5889,6 +5913,27 @@
"expo": "*"
}
},
+ "node_modules/expo-image-loader": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
+ "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-image-picker": {
+ "version": "17.0.11",
+ "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz",
+ "integrity": "sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "expo-image-loader": "~6.0.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-linking": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
@@ -6369,6 +6414,16 @@
}
}
},
+ "node_modules/expo-web-browser": {
+ "version": "15.0.11",
+ "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
+ "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo/node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -6571,21 +6626,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
- "node_modules/expo/node_modules/expo-asset": {
- "version": "12.0.12",
- "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
- "integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==",
- "license": "MIT",
- "dependencies": {
- "@expo/image-utils": "^0.8.8",
- "expo-constants": "~18.0.12"
- },
- "peerDependencies": {
- "expo": "*",
- "react": "*",
- "react-native": "*"
- }
- },
"node_modules/expo/node_modules/expo-font": {
"version": "14.0.11",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
@@ -9665,6 +9705,32 @@
"react-native": "*"
}
},
+ "node_modules/react-native-webview": {
+ "version": "13.15.0",
+ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz",
+ "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "invariant": "2.2.4"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
+ "node_modules/react-native-webview/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/react-native-worklets": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
diff --git a/package.json b/package.json
index a2da6bf..ac1cbb0 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "lint": "npm run typecheck && node scripts/lint-no-debug.js"
},
"dependencies": {
"@expo/vector-icons": "^15.1.1",
@@ -16,13 +17,15 @@
"axios": "^1.15.2",
"babel-preset-expo": "~54.0.10",
"expo": "~54.0.33",
- "expo-av": "~16.0.8",
+ "expo-asset": "~12.0.13",
+ "expo-audio": "~1.1.1",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.22",
"expo-haptics": "~15.0.8",
+ "expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.11",
"expo-location": "~19.0.8",
"expo-notifications": "~0.32.16",
diff --git a/src/ai/aiClient.ts b/src/ai/aiClient.ts
index 1ff8530..ceab3da 100644
--- a/src/ai/aiClient.ts
+++ b/src/ai/aiClient.ts
@@ -136,14 +136,19 @@ Rispondi SOLO con un oggetto JSON valido. Nessun markdown, nessun testo extra.
La radice deve avere ESATTAMENTE queste chiavi: "manifest", "ui", "code".
Schema obbligatorio:
-{"manifest":{"id":"id-minuscolo","name":"Nome","version":"1.0.0","runtime":"javascript","permissions":[],"entry":"logic.js","ui":"ui.json"},"ui":{"type":"screen","title":"Nome","components":[{"type":"gameView","id":"game","bind":"scene","width":320,"height":420,"tickMs":60,"tickAction":"onTick","onTapAction":"onTap"}]},"code":"module.exports={actions:{async onTick(api,input,state){return{scene:[]};},async onTap(api,input,state){return{};}}};" }
+{"manifest":{"id":"id-minuscolo","name":"Nome","version":"1.0.0","runtime":"javascript","permissions":[],"entry":"logic.js","ui":"ui.json"},"ui":{"type":"screen","title":"Nome","gap":0,"theme":{"bg":"#000"},"components":[{"type":"webGame","id":"game","width":360,"height":600}]},"code":"GameKit.startPlatformer({gravityY:0.65,width:WIDTH,height:HEIGHT,controls:{speed:3.2,jumpPower:12},levels:[{name:'Livello 1',player:{id:'player',x:30,y:470,w:26,h:32},goal:{x:310,y:486,w:30,h:46},bodies:[{id:'floor',x:0,y:552,w:360,h:48,static:true},{id:'coin1',tag:'coin',sensor:true,static:true,solid:false,type:'circle',x:150,y:510,r:8,color:'#facc15'}]},{name:'Livello 2',player:{id:'player',x:30,y:470,w:26,h:32},goal:{x:315,y:390,w:28,h:52},bodies:[{id:'floor',x:0,y:552,w:360,h:48,static:true},{id:'p1',x:120,y:472,w:80,h:18,static:true},{id:'hazard',tag:'hazard',sensor:true,static:true,solid:false,x:230,y:536,w:44,h:16,color:'#ef4444'}]}]});" }
-Regole critiche per gameView:
-- onTick MAX 10 righe. Usa Math.min/Math.max per bounds, MAI if chains ripetuti.
+Regole critiche per webGame:
+- Il code è browser JavaScript, NON module.exports. Usa requestAnimationFrame.
+- Per giochi platform/action usa GameKit.startPlatformer: include fisica, collisioni, livelli, HUD, transizioni, particelle, score e restart.
- Non usare mai link Markdown nel code: scrivi state.x non [state.x](http://state.x).
-- Le action sono sorelle dentro actions separate da virgola.
- Il code è UNA stringa JSON (tutti i \\n e \\" escaped se necessario).
+Regole critiche per timer/countdown:
+- Nei moduli normali NON usare setInterval/setTimeout nel code.
+- Aggiungi in UI { "type":"timer","id":"mainTimer","tickAction":"tick","intervalMs":1000,"activeBind":"running","autoStart":false }.
+- Start/pausa/reset sono button action che impostano running e valori numerici; tick restituisce il patch di stato.
+
Inizio risposta precedente:
${previousContent.slice(0, 400)}
diff --git a/src/ai/builtinGameTemplates.ts b/src/ai/builtinGameTemplates.ts
index 83972ae..fbb4ae6 100644
--- a/src/ai/builtinGameTemplates.ts
+++ b/src/ai/builtinGameTemplates.ts
@@ -51,7 +51,8 @@ const FLAPPY_CODE =
`const lastX=pipes.length>0?pipes[pipes.length-1].x:0;` +
`if(lastXp.x+2>=BX&&p.xH-BR||pipes.some(p=>Math.abs(p.x-BX)
p.gap+GAP));` +
+ `const HR=9;const cr=(cx,cy,r,rx,ry,rw,rh)=>{const nx=Math.max(rx,Math.min(cx,rx+rw)),ny2=Math.max(ry,Math.min(cy,ry+rh)),dx=cx-nx,dy=cy-ny2;return dx*dx+dy*dyH-BR||pipes.some(p=>cr(BX,ny,HR,p.x-20,0,40,Math.max(0,p.gap-GAP))||cr(BX,ny,HR,p.x-20,p.gap+GAP,40,H));` +
`const scene=[{type:'rect',x:0,y:0,w:W,h:H,color:'#70c5ce'}];` +
`pipes.forEach(p=>{` +
`scene.push({type:'rect',x:p.x-20,y:0,w:40,h:Math.max(0,p.gap-GAP),color:'#5d8a3c'});` +
diff --git a/src/ai/modulePrompt.ts b/src/ai/modulePrompt.ts
index 3ace021..003c737 100644
--- a/src/ai/modulePrompt.ts
+++ b/src/ai/modulePrompt.ts
@@ -29,13 +29,14 @@ MANIFEST
UI (albero dichiarativo)
- Radice: { "type": "screen", "title": "...", "components": [ ... ] } oppure { "type": "navigator", ... } per moduli multi-pagina.
- In "components" (screen, box, card) ogni elemento deve essere un OGGETTO con "type". Vietato inserire stringhe, numeri o null come elementi dell'array (errore di validazione).
-- Tipi ammessi: navigator, screen, box, text, input, textarea, button, list, card, image, audioRecorder, qrScanner, webGame, gamepad. Non usare type "row" o "column" da soli: usa "box" con "direction": "row" | "column".
+- Tipi ammessi: navigator, screen, box, text, input, textarea, button, list, card, image, audioRecorder, qrScanner, timer, webGame, gamepad. Non usare type "row" o "column" da soli: usa "box" con "direction": "row" | "column".
- box: contenitore flex. direction "row" (in riga) o "column" (default). Opzionali: gap, padding, wrap (boolean), alignItems (stretch|flex-start|flex-end|center|baseline), justifyContent (flex-start|flex-end|center|space-between|space-around|space-evenly), components: [ ... ]. Per griglie (calcolatrici, tastierini) usa più box annidate: una box column di righe, ogni riga una box row; sui button in riga usa "layout": { "flex": 1 } per larghezze uniformi.
- layout (opzionale sui componenti): flex, flexGrow, flexShrink, width (numero o stringa tipo "32%"), minWidth, maxWidth, alignSelf, margin*, textAlign (left|center|right) per testo/campi. NON usare position absolute nel JSON: usa box + flex.
- button: id, text. Se chiama codice: action (nome funzione in actions), actionInput opzionale. Se naviga: "navigate": "nomeSchermata" (oppure "__back" per tornare indietro) — in questo caso action non è necessaria. Le action hanno SEMPRE firma esattamente (api, input, state).
- input / textarea: id, bind obbligatori; placeholder, keyboardType (default|numeric|decimal-pad) dove serve.
- text: opzionale bind (mostra stato); se serve valore iniziale usa anche "text".
- list: bind (array in stato). image: bind (URI stringa). card: components annidati.
+- timer: componente invisibile gestito dall'host, per countdown/cronometri/pomodoro. Forma: { "type": "timer", "id": "mainTimer", "tickAction": "tick", "intervalMs": 1000, "activeBind": "running", "autoStart": false, "runImmediately": false }. Se activeBind è presente, il timer gira solo quando state[activeBind] è truthy; con activeBind l'avvio iniziale è false salvo "autoStart": true. Se activeBind manca, autoStart default true. NON usare setInterval/setTimeout nel code dei moduli normali: usa sempre il componente timer.
NAVIGAZIONE MULTI-PAGINA (navigator)
- Usa "navigator" come radice quando il modulo ha più schermate separate.
@@ -62,12 +63,13 @@ CODE (stringa JavaScript eseguita in Hermes)
- Apri siti web: usa SEMPRE il componente webview nell'UI (src: url) — non api.linking. Per email/telefono/sms: usa api.linking con permesso "linking" in manifest. Non usare oggetti globali Linking.
- Vietato nel sorgente: eval, Function, new Function, require, import/export ESM, process, global/globalThis, __dirname, __filename, fs, import dinamico, Linking., while(true), for(;;).
- Calcolatrici / formule: MAI eval(...) o Function(...) sull'espressione mostrata — il modulo viene rifiutato. Usa stato (es. display, valore accumulato, operatore corrente) e nelle action applica + − × ÷ tra numeri già noti, oppure costruisci il risultato tasto per tasto come fanno le calcolatrici classiche.
+- Timer / countdown / cronometri: MAI usare setInterval o setTimeout nel code. Aggiungi un componente { "type":"timer","tickAction":"tick","intervalMs":1000,"activeBind":"running" }. L'action tick riceve input { timerId, tick:true, intervalMs, now } e deve restituire il patch di stato. Start/pausa/reset sono normali button action che impostano running e i valori numerici.
API DISPONIBILI (solo con permesso manifest + consenso utente)
-- Fotocamera: permesso "camera" → const photo = await api.camera.takePhoto(); → { uri, width, height } | null. Mostra con componente image (bind a uri).
+- Fotocamera: permesso "camera" → const photo = await api.camera.takePhoto(); → { uri, fileUri, width, height } | null. Mostra subito con componente image usando photo.uri. Se devi salvare il file permanente, preferisci photo.fileUri || photo.uri.
- Microfono / registrazione: permesso "audioRecorder" → await api.audioRecorder.start(); → await api.audioRecorder.stop(); → { uri, durationMs } | null. L'URI è persistente e riascoltabile.
- Riproduzione audio: permesso "audioRecorder" (stesso permesso) → await api.audioPlayer.play(uri); → { durationMs }. Controlla: await api.audioPlayer.stop(); await api.audioPlayer.pause(); await api.audioPlayer.resume(); await api.audioPlayer.getStatus(); → { isPlaying, positionMs, durationMs }.
-- Salvataggio URI file (audio/foto): usa api.storage.save('chiave', uri) per memorizzare l'URI restituito da stop() o takePhoto(). Recupera con const uri = await api.storage.load('chiave'). L'URI rimane valido tra le sessioni.
+- Salvataggio URI file (audio/foto): per audio usa l'URI restituito da stop(); per foto usa photo.fileUri || photo.uri. Recupera con const uri = await api.storage.load('chiave'). L'URI rimane valido tra le sessioni.
- QR scanner: permesso "qrScanner" → const text = await api.qrScanner.scan(); → stringa | null.
- Torcia: permesso "torch" → await api.torch.setEnabled(true|false). La torcia si spegne automaticamente all'uscita dal modulo.
- GPS/posizione: permesso "location" → const pos = await api.location.getCurrentPosition(); → { latitude, longitude, accuracy, altitude, heading, speed, timestamp }.
@@ -85,8 +87,9 @@ API DISPONIBILI (solo con permesso manifest + consenso utente)
PATTERN CONSIGLIATI
- Registra audio e poi riascolta: start → stop (salva uri in stato) → play(uri). Salva uri con api.storage.save se serve tra sessioni.
-- Foto con visualizzazione: takePhoto → salva photo.uri in stato bind a image. Salva uri con api.storage.save per riaverla.
+- Foto con visualizzazione: takePhoto → salva photo.uri in stato bind a image per anteprima immediata. Per persistenza salva photo.fileUri || photo.uri con api.storage.save.
- Flusso con più step: usa stato intermedio con bind text (es. status: ''), aggiorna con return { status: 'Registrazione avviata' }.
+- Timer affidabile: text bind "display"/"status" + timer invisibile con activeBind "running" + action tick che decrementa/incrementa usando fallback numerici. Esempio tick: const remaining=Math.max(0,(parseInt(String(state.remaining??'60'),10)||0)-1); return { remaining, display: String(remaining), running: remaining>0, status: remaining===0?'Finito':'In corso' };
STATO SICURO (obbligatorio — Hermes lancia ReferenceError se accedi a proprietà non inizializzate)
- REGOLA ASSOLUTA: ogni lettura da state deve usare un fallback esplicito. Al primo avvio state contiene solo i valori iniziali dei componenti UI — tutte le altre chiavi sono undefined. Un accesso diretto a undefined causa crash.
@@ -119,6 +122,7 @@ Il code è JavaScript puro che gira nel WebView. Variabili globali GIÀ disponib
- canvas, ctx, WIDTH, HEIGHT — canvas già inizializzato e scalato
- sendState(patch) — opzionale: invia dati allo stato React Native (score, ecc.)
- window.__onBtn(id, pressed) — opzionale: ricevi eventi da gamepad React Native esterno
+- GameKit — helper host per fisica, collisioni, sensori, controlli platform e livelli
⚠️ VIETATO ASSOLUTO nel code webGame:
- NON scrivere: var canvas, var ctx, var WIDTH, var HEIGHT, var sendState, var window
@@ -133,6 +137,33 @@ REGOLE CODE webGame:
- INPUT: gestisci TUTTO il touch direttamente sul canvas (più affidabile)
- Gestisci game over e restart dentro il loop (nessun reload pagina)
- Disegna i controlli virtuali DENTRO il canvas se servono pulsanti visibili
+- Platform / puzzle / action con gravità: usa GameKit.create(...) + setLevels(...), NON reinventare collisioni AABB complesse.
+- Esperienza “wow”: per giochi a livelli usa preferibilmente GameKit.startPlatformer(...). Include già HUD, score, transizione livelli, particelle, shake, restart su game over, goal, hazard e collectibles.
+- Giochi tipo Flappy/dodge: NON usare collisioni a quadrato pieno sul personaggio se è disegnato come cerchio. Usa collisione cerchio-rettangolo con hitbox ridotta (hitR circa 70-75% del raggio visivo), così il game over coincide con ciò che l'utente vede.
+- Ogni gioco non banale deve avere almeno 3 livelli con difficoltà crescente, goal chiaro e restart in caso di caduta/morte.
+
+PATTERN PLATFORM CON FISICA + LIVELLI (usa questo per Mario-like, platform, puzzle fisici):
+GameKit.startPlatformer({gravityY:0.65,width:WIDTH,height:HEIGHT,background:'#0f172a',controls:{speed:3.2,jumpPower:12},levels:[
+ {name:'Livello 1',background:'#12213a',player:{id:'player',x:34,y:470,w:26,h:32},goal:{x:310,y:486,w:30,h:46},bodies:[
+ {id:'floor',x:0,y:552,w:360,h:48,static:true,color:'#334155'},
+ {id:'p1',x:120,y:472,w:86,h:18,static:true,color:'#64748b'},
+ {id:'coin1',tag:'coin',sensor:true,static:true,solid:false,type:'circle',x:150,y:432,r:8,color:'#facc15'}
+ ]},
+ {name:'Livello 2',background:'#172554',player:{id:'player',x:30,y:470,w:26,h:32},goal:{x:318,y:390,w:28,h:52},bodies:[
+ {id:'floor',x:0,y:552,w:360,h:48,static:true,color:'#334155'},
+ {id:'p1',x:72,y:486,w:68,h:18,static:true,color:'#64748b'},
+ {id:'p2',x:178,y:432,w:72,h:18,static:true,color:'#64748b'},
+ {id:'p3',x:284,y:486,w:76,h:18,static:true,color:'#64748b'},
+ {id:'spike',tag:'hazard',sensor:true,static:true,solid:false,x:146,y:536,w:42,h:16,color:'#ef4444'}
+ ]},
+ {name:'Livello 3',background:'#1e1b4b',player:{id:'player',x:24,y:470,w:26,h:32},goal:{x:310,y:314,w:28,h:52},bodies:[
+ {id:'floorL',x:0,y:552,w:116,h:48,static:true,color:'#334155'},
+ {id:'floorR',x:244,y:552,w:116,h:48,static:true,color:'#334155'},
+ {id:'p1',x:124,y:500,w:58,h:18,static:true,color:'#64748b'},
+ {id:'p2',x:206,y:440,w:58,h:18,static:true,color:'#64748b'},
+ {id:'p3',x:286,y:368,w:62,h:18,static:true,color:'#64748b'}
+ ]}
+]});
I 3 PATTERN BASE — INPUT via TOUCH DIRETTO sul canvas (NESSUN bridge esterno):
@@ -140,6 +171,7 @@ PATTERN TAP (Flappy Bird, gravity, pallina che salta):
canvas.addEventListener('touchstart',function(e){e.preventDefault();if(!alive){restart();}else{vy=-9;}},{passive:false});
var x=80,y=HEIGHT/2,vy=0,pipes=[],score=0,alive=true,dist=0;
function restart(){y=HEIGHT/2;vy=0;pipes=[];score=0;alive=true;dist=0;}
+function circleRect(cx,cy,r,rx,ry,rw,rh){var nx=Math.max(rx,Math.min(cx,rx+rw));var ny=Math.max(ry,Math.min(cy,ry+rh));var dx=cx-nx,dy=cy-ny;return dx*dx+dy*dy-54;});
pipes.forEach(function(p){if(!p.passed&&p.x+54HEIGHT-14||pipes.some(function(p){return x+14>p.x&&x-14p.gap+65);});
+ var hitR=10; /* raggio visivo 14, hitbox leggermente più piccola per evitare game over ingiusti */
+ var dead=yHEIGHT-40-hitR||pipes.some(function(p){return circleRect(x,y,hitR,p.x,0,54,p.gap-65)||circleRect(x,y,hitR,p.x,p.gap+65,54,HEIGHT-(p.gap+65));});
if(dead)alive=false;
ctx.fillStyle='#87CEEB';ctx.fillRect(0,0,WIDTH,HEIGHT);
pipes.forEach(function(p){ctx.fillStyle='#22c55e';ctx.fillRect(p.x,0,54,p.gap-65);ctx.fillRect(p.x,p.gap+65,54,HEIGHT);});
@@ -256,30 +289,32 @@ ESEMPIO 2 — multi-pagina con navigator + onFocus per ricaricare lista (usa SEM
"code": "module.exports = { actions: { async caricaLista(api, input, state) { const raw = await api.storage.load('items'); const arr = raw ? JSON.parse(raw) : []; return { items: arr }; }, async salvaItem(api, input, state) { const testo = String(state.testo ?? ''); if (!testo) return { statoSalvataggio: 'Inserisci un testo.' }; const raw = await api.storage.load('items'); const arr = raw ? JSON.parse(raw) : []; arr.push(testo); await api.storage.save('items', JSON.stringify(arr)); return { testo: '', __navigate: '__back' }; } } };"
}`;
-const GAME_EXAMPLE = `ESEMPIO 3 — Flappy Bird con webGame (canvas 2D, 60fps nativi):
+const GAME_EXAMPLE = `ESEMPIO 3 — platform con webGame + GameKit (fisica, collisioni, livelli):
{
- "manifest": { "id": "flappy-bird", "name": "Flappy Bird", "version": "1.0.0", "runtime": "javascript", "permissions": [], "entry": "logic.js", "ui": "ui.json" },
+ "manifest": { "id": "platform-levels", "name": "Platform Levels", "version": "1.0.0", "runtime": "javascript", "permissions": [], "entry": "logic.js", "ui": "ui.json" },
"ui": {
- "type": "screen", "title": "Flappy Bird", "gap": 0,
+ "type": "screen", "title": "Platform Levels", "gap": 0,
"theme": { "bg": "#000" },
"components": [
{ "type": "webGame", "id": "game", "width": 360, "height": 600 }
]
},
- "code": "var by=HEIGHT/2,bvy=0,pipes=[],score=0,alive=true,dist=0;function restart(){by=HEIGHT/2;bvy=0;pipes=[];score=0;alive=true;dist=0;}canvas.addEventListener('touchstart',function(e){e.preventDefault();if(!alive){restart();}else{bvy=-9;}},{passive:false});var last=0;function loop(ts){var dt=Math.min((ts-last)/1000,0.1);last=ts;if(alive){bvy+=0.45;by+=bvy;dist++;if(dist%90===0){pipes.push({x:WIDTH+10,gapY:100+Math.floor(Math.random()*(HEIGHT-220))});}pipes.forEach(function(p){p.x-=3;});pipes=pipes.filter(function(p){return p.x>-54;});pipes.forEach(function(p){if(!p.passed&&p.x+54<80){p.passed=true;score++;sendState({score:score});}});var dead=by<14||by>HEIGHT-14||pipes.some(function(p){return 80+14>p.x&&80-14p.gapY+65);});if(dead)alive=false;}ctx.fillStyle='#87CEEB';ctx.fillRect(0,0,WIDTH,HEIGHT);ctx.fillStyle='#5DBB3F';ctx.fillRect(0,HEIGHT-40,WIDTH,40);pipes.forEach(function(p){ctx.fillStyle='#22c55e';ctx.fillRect(p.x,0,54,p.gapY-65);ctx.fillRect(p.x,p.gapY+65,54,HEIGHT);ctx.fillStyle='#16a34a';ctx.fillRect(p.x-3,p.gapY-65-20,60,20);ctx.fillRect(p.x-3,p.gapY+65,60,20);});ctx.beginPath();ctx.arc(80,by,14,0,Math.PI*2);ctx.fillStyle='#facc15';ctx.fill();ctx.strokeStyle='#ca8a04';ctx.lineWidth=2;ctx.stroke();ctx.fillStyle='#fff';ctx.font='bold 28px sans-serif';ctx.textAlign='center';ctx.fillText(score,WIDTH/2,40);if(!alive){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,WIDTH,HEIGHT);ctx.fillStyle='#f87171';ctx.font='bold 32px sans-serif';ctx.textAlign='center';ctx.fillText('GAME OVER',WIDTH/2,HEIGHT/2-30);ctx.fillStyle='#fff';ctx.font='bold 20px sans-serif';ctx.fillText('Score: '+score,WIDTH/2,HEIGHT/2+10);ctx.fillStyle='#aaa';ctx.font='15px sans-serif';ctx.fillText('Tap to restart',WIDTH/2,HEIGHT/2+45);}requestAnimationFrame(loop);}requestAnimationFrame(loop);"
+ "code": "GameKit.startPlatformer({gravityY:0.65,width:WIDTH,height:HEIGHT,background:'#0f172a',controls:{speed:3.2,jumpPower:12},levels:[{name:'Livello 1',background:'#12213a',player:{id:'player',x:34,y:470,w:26,h:32},goal:{x:310,y:486,w:30,h:46},bodies:[{id:'floor',x:0,y:552,w:360,h:48,static:true,color:'#334155'},{id:'p1',x:120,y:472,w:86,h:18,static:true,color:'#64748b'},{id:'coin1',tag:'coin',sensor:true,static:true,solid:false,type:'circle',x:150,y:432,r:8,color:'#facc15'}]},{name:'Livello 2',background:'#172554',player:{id:'player',x:30,y:470,w:26,h:32},goal:{x:318,y:390,w:28,h:52},bodies:[{id:'floor',x:0,y:552,w:360,h:48,static:true,color:'#334155'},{id:'p1',x:72,y:486,w:68,h:18,static:true,color:'#64748b'},{id:'p2',x:178,y:432,w:72,h:18,static:true,color:'#64748b'},{id:'spike',tag:'hazard',sensor:true,static:true,solid:false,x:146,y:536,w:42,h:16,color:'#ef4444'}]},{name:'Livello 3',background:'#1e1b4b',player:{id:'player',x:24,y:470,w:26,h:32},goal:{x:310,y:314,w:28,h:52},bodies:[{id:'floorL',x:0,y:552,w:116,h:48,static:true,color:'#334155'},{id:'floorR',x:244,y:552,w:116,h:48,static:true,color:'#334155'},{id:'p1',x:124,y:500,w:58,h:18,static:true,color:'#64748b'},{id:'p2',x:206,y:440,w:58,h:18,static:true,color:'#64748b'},{id:'p3',x:286,y:368,w:62,h:18,static:true,color:'#64748b'}]}]});"
}`;
/** Blocco da mostrare sopra agli errori di generazione/validazione/compilazione (stesso "contratto" del modello). */
export function getJsonResponseRetryHint(): string {
return `Rigenera un UNICO JSON valido (solo chiavi manifest, ui, code — niente markdown né testo extra).
GIOCHI: usa SEMPRE { "type":"webGame","width":360,"height":600 } nell'UI. Il code è JavaScript browser (canvas 2D + requestAnimationFrame), NON module.exports.
- Globals già pronti (NON ridichiarare): canvas, ctx, WIDTH, HEIGHT, sendState.
+ Globals già pronti (NON ridichiarare): canvas, ctx, WIDTH, HEIGHT, sendState, GameKit.
+ Platform/action: usa preferibilmente GameKit.startPlatformer({levels:[...]}) per avere fisica, livelli, HUD, transizioni, particelle, score e restart.
VIETATO: var canvas, var ctx, var WIDTH, var HEIGHT, var window, function init(){canvas=document...}.
Touch: canvas.addEventListener('touchstart',fn,{passive:false}), e.preventDefault() dentro.
Split-screen: touchLeft=(e.touches[0].clientX void } | null = null;
const stopAndUnload = async () => {
if (!sound) return;
const s = sound;
sound = null;
- try { await s.stopAsync(); } catch { /* già fermato */ }
- try { await s.unloadAsync(); } catch { /* già scaricato */ }
+ playbackSubscription?.remove();
+ playbackSubscription = null;
+ try { s.pause(); } catch { /* già fermato */ }
+ try { s.remove(); } catch { /* già scaricato */ }
registry.unregister('audioPlayer');
};
return {
async play(uri: string): Promise<{ durationMs?: number }> {
await stopAndUnload();
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: false,
- playsInSilentModeIOS: true,
+ await setAudioModeAsync({
+ allowsRecording: false,
+ playsInSilentMode: true,
});
- const { sound: s } = await Audio.Sound.createAsync(
- { uri },
- { shouldPlay: true }
- );
+ const s = createAudioPlayer(uri);
sound = s;
registry.register('audioPlayer', stopAndUnload);
- s.setOnPlaybackStatusUpdate((st) => {
+ playbackSubscription = s.addListener('playbackStatusUpdate', (st) => {
if (st.isLoaded && st.didJustFinish) {
void stopAndUnload();
}
});
- const status = await s.getStatusAsync();
+ s.play();
+ const status = s.currentStatus;
return {
- durationMs: status.isLoaded ? status.durationMillis : undefined,
+ durationMs: status.isLoaded && status.duration ? Math.round(status.duration * 1000) : undefined,
};
},
@@ -45,24 +47,24 @@ export function createAudioPlayerCapability(registry: CapabilityRegistry) {
async pause(): Promise {
if (sound) {
- try { await sound.pauseAsync(); } catch { /* ignora */ }
+ try { sound.pause(); } catch { /* ignora */ }
}
},
async resume(): Promise {
if (sound) {
- try { await sound.playAsync(); } catch { /* ignora */ }
+ try { sound.play(); } catch { /* ignora */ }
}
},
async getStatus(): Promise<{ isPlaying: boolean; positionMs?: number; durationMs?: number }> {
if (!sound) return { isPlaying: false };
- const st = await sound.getStatusAsync().catch(() => null);
- if (!st || !st.isLoaded) return { isPlaying: false };
+ const st = sound.currentStatus;
+ if (!st.isLoaded) return { isPlaying: false };
return {
- isPlaying: st.isPlaying,
- positionMs: st.positionMillis,
- durationMs: st.durationMillis,
+ isPlaying: st.playing,
+ positionMs: Math.round(st.currentTime * 1000),
+ durationMs: st.duration ? Math.round(st.duration * 1000) : undefined,
};
},
};
diff --git a/src/capabilities/audioRecorder.ts b/src/capabilities/audioRecorder.ts
index 6516b5a..6090183 100644
--- a/src/capabilities/audioRecorder.ts
+++ b/src/capabilities/audioRecorder.ts
@@ -1,30 +1,31 @@
-import { Audio } from 'expo-av';
+import { AudioModule, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
+import type { AudioRecorder } from 'expo-audio';
import type { CapabilityRegistry } from './capabilityRegistry';
export function createAudioRecorderCapability(registry: CapabilityRegistry) {
- let active: Audio.Recording | null = null;
+ let active: AudioRecorder | null = null;
const forceStop = async () => {
if (!active) return;
const rec = active;
active = null;
registry.unregister('audioRecorder');
- try { await rec.stopAndUnloadAsync(); } catch { /* ignora */ }
- try { await Audio.setAudioModeAsync({ allowsRecordingIOS: false }); } catch { /* ignora */ }
+ try { await rec.stop(); } catch { /* ignora */ }
+ try { await setAudioModeAsync({ allowsRecording: false }); } catch { /* ignora */ }
};
return {
async start() {
if (active) throw new Error('Registrazione già attiva');
- const perm = await Audio.requestPermissionsAsync();
+ const perm = await requestRecordingPermissionsAsync();
if (!perm.granted) throw new Error('Permesso microfono negato');
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: true,
- playsInSilentModeIOS: true,
+ await setAudioModeAsync({
+ allowsRecording: true,
+ playsInSilentMode: true,
});
- const recording = new Audio.Recording();
- await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
- await recording.startAsync();
+ const recording = new AudioModule.AudioRecorder(RecordingPresets.HIGH_QUALITY);
+ await recording.prepareToRecordAsync();
+ recording.record();
active = recording;
registry.register('audioRecorder', forceStop);
},
@@ -35,15 +36,14 @@ export function createAudioRecorderCapability(registry: CapabilityRegistry) {
active = null;
registry.unregister('audioRecorder');
try {
- const uri = rec.getURI();
- await rec.stopAndUnloadAsync();
- const status = await rec.getStatusAsync().catch(() => null);
- const durationMs =
- status && 'durationMillis' in status ? (status.durationMillis as number | undefined) : undefined;
+ await rec.stop();
+ const status = rec.getStatus();
+ const uri = status.url ?? rec.uri;
+ const durationMs = status.durationMillis;
if (!uri) return null;
return { uri, durationMs };
} finally {
- await Audio.setAudioModeAsync({ allowsRecordingIOS: false }).catch(() => {});
+ await setAudioModeAsync({ allowsRecording: false }).catch(() => {});
}
},
};
diff --git a/src/capabilities/fileStorage.ts b/src/capabilities/fileStorage.ts
index 9222fcb..25d4854 100644
--- a/src/capabilities/fileStorage.ts
+++ b/src/capabilities/fileStorage.ts
@@ -27,6 +27,22 @@ export function createFileStorageCapability(moduleId: string) {
return {
async save(key: string, sourceUri: string): Promise<{ uri: string }> {
+ if (sourceUri.startsWith('data:image/')) {
+ const match = sourceUri.match(/^data:image\/([a-zA-Z0-9.+-]+);base64,(.*)$/);
+ if (!match) throw new Error('Data URI immagine non valido');
+ const [, rawExt, base64] = match;
+ const dir = moduleDir(moduleId);
+ if (!dir.exists) dir.create({ intermediates: true, idempotent: true });
+ const ext = rawExt === 'jpeg' ? 'jpg' : rawExt;
+ const dest = destFile(moduleId, key, ext);
+ dest.write(base64, { encoding: 'base64' });
+
+ const idx = await getIndex(indexKey);
+ idx[key] = dest.uri;
+ await setIndex(indexKey, idx);
+ return { uri: dest.uri };
+ }
+
const dir = moduleDir(moduleId);
if (!dir.exists) dir.create({ intermediates: true, idempotent: true });
diff --git a/src/capabilities/types.ts b/src/capabilities/types.ts
index 6a00e65..f27991a 100644
--- a/src/capabilities/types.ts
+++ b/src/capabilities/types.ts
@@ -5,7 +5,7 @@ export type TorchCapability = {
export type MotherApi = {
camera: {
- takePhoto: () => Promise<{ uri: string; width?: number; height?: number } | null>;
+ takePhoto: () => Promise<{ uri: string; fileUri?: string; width?: number; height?: number } | null>;
};
audioRecorder: {
start: () => Promise;
diff --git a/src/modules/moduleValidator.ts b/src/modules/moduleValidator.ts
index 3730cf8..9107c18 100644
--- a/src/modules/moduleValidator.ts
+++ b/src/modules/moduleValidator.ts
@@ -29,6 +29,10 @@ function looksLikeWebGameCode(code: string): boolean {
);
}
+function looksLikeWebGameNetworkCode(code: string): boolean {
+ return /\b(fetch|XMLHttpRequest|WebSocket|EventSource)\b|\bnavigator\s*\.\s*sendBeacon\b|\bimportScripts\s*\(/.test(code);
+}
+
function collectButtonActions(node: UiNode): string[] {
// Se il button ha navigate, l'action è opzionale e non va validata
if (node.type === 'button') return node.action && !node.navigate ? [node.action] : [];
@@ -42,6 +46,9 @@ function collectButtonActions(node: UiNode): string[] {
if (node.type === 'gamepad') {
return node.buttons.map((b) => b.action).filter(Boolean);
}
+ if (node.type === 'timer') {
+ return node.tickAction ? [node.tickAction] : [];
+ }
if (node.type === 'gameView') {
const acts: string[] = [];
@@ -128,6 +135,24 @@ export function validateGeneratedModule(raw: unknown): ValidateResult {
const codeIsWebGame = looksLikeWebGameCode(parsed.data.code);
const isWebGame = uiHasWebGame || codeIsWebGame;
+ const scan = scanGeneratedCode(parsed.data.code);
+ if (!scan.ok) {
+ const err = formatCodeScanError(scan.reason);
+ reportGenAppError('moduleValidator.codeScan', new Error(scan.reason), {
+ codeHead: parsed.data.code.slice(0, 800),
+ isWebGame,
+ });
+ return { ok: false, error: err };
+ }
+
+ if (isWebGame && looksLikeWebGameNetworkCode(parsed.data.code) && !parsed.data.manifest.permissions.includes('network')) {
+ const err = 'I giochi WebView che usano rete devono dichiarare il permesso "network" nel manifest.';
+ reportGenAppError('moduleValidator.webGameNetworkPermission', new Error(err), {
+ codeHead: parsed.data.code.slice(0, 800),
+ });
+ return { ok: false, error: err };
+ }
+
if (isWebGame && !uiHasWebGame) {
// AI generated browser-style game code but forgot the webGame UI node.
// Inject a webGame node so DynamicRenderer knows to skip Hermes and use WebView.
@@ -136,15 +161,6 @@ export function validateGeneratedModule(raw: unknown): ValidateResult {
}
if (!isWebGame) {
- const scan = scanGeneratedCode(parsed.data.code);
- if (!scan.ok) {
- const err = formatCodeScanError(scan.reason);
- reportGenAppError('moduleValidator.codeScan', new Error(scan.reason), {
- codeHead: parsed.data.code.slice(0, 800),
- });
- return { ok: false, error: err };
- }
-
// Cross-valida: ogni action dichiarata nei button deve esistere nel codice compilato.
const compileResult = tryCompileModuleActions(parsed.data.code);
if (!compileResult.ok) {
diff --git a/src/renderer/CameraCaptureModal.tsx b/src/renderer/CameraCaptureModal.tsx
index 246ff89..6cf6769 100644
--- a/src/renderer/CameraCaptureModal.tsx
+++ b/src/renderer/CameraCaptureModal.tsx
@@ -1,49 +1,60 @@
-import { useCallback, useLayoutEffect, useRef, useState } from 'react';
+import { useCallback, useLayoutEffect, useState } from 'react';
import { ActivityIndicator, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
-import { CameraView, useCameraPermissions } from 'expo-camera';
+import { File, Paths } from 'expo-file-system';
+import * as ImagePicker from 'expo-image-picker';
import { registerCameraCaptureUiOpener, resolveCameraPhoto } from '../capabilities/cameraBridge';
-type CameraRef = {
- takePictureAsync: (options?: {
- quality?: number;
- base64?: boolean;
- skipProcessing?: boolean;
- }) => Promise<{ uri: string; width?: number; height?: number }>;
-};
-
export function CameraCaptureModalHost() {
const [visible, setVisible] = useState(false);
const [busy, setBusy] = useState(false);
- const [permission, requestPermission] = useCameraPermissions();
- const cameraRef = useRef(null);
- const close = useCallback((result: { uri: string; width?: number; height?: number } | null) => {
+ const close = useCallback((result: { uri: string; fileUri?: string; width?: number; height?: number } | null) => {
setBusy(false);
setVisible(false);
resolveCameraPhoto(result);
}, []);
useLayoutEffect(() => {
- registerCameraCaptureUiOpener(() => setVisible(true));
+ registerCameraCaptureUiOpener(() => {
+ setVisible(true);
+ });
return () => registerCameraCaptureUiOpener(null);
}, []);
const takePhoto = useCallback(async () => {
if (busy) return;
- // Se la camera non è ancora inizializzata, risolvi con null invece di
- // restare appesi: l'action riceve null e può gestirlo (es. "Foto annullata").
- if (!cameraRef.current) {
- close(null);
- return;
- }
setBusy(true);
try {
- const photo = await cameraRef.current.takePictureAsync({
- quality: 0.85,
- base64: false,
- skipProcessing: false,
+ const permission = await ImagePicker.requestCameraPermissionsAsync();
+ if (!permission.granted) {
+ close(null);
+ return;
+ }
+
+ const result = await ImagePicker.launchCameraAsync({
+ mediaTypes: ['images'],
+ allowsEditing: false,
+ quality: 0.55,
+ base64: true,
});
- close({ uri: photo.uri, width: photo.width, height: photo.height });
+
+ if (result.canceled || !result.assets?.[0]) {
+ close(null);
+ return;
+ }
+
+ const photo = result.assets[0];
+ let finalUri = photo.uri;
+ try {
+ const src = new File(photo.uri);
+ const dest = new File(Paths.document, `cam_${Date.now()}.jpg`);
+ src.copy(dest);
+ finalUri = dest.uri;
+ } catch {
+ // fallback: use original URI
+ }
+ const previewUri = photo.base64 ? `data:image/jpeg;base64,${photo.base64}` : finalUri;
+ close({ uri: previewUri, fileUri: finalUri, width: photo.width, height: photo.height });
} catch {
close(null);
}
@@ -55,35 +66,15 @@ export function CameraCaptureModalHost() {
Fotocamera
- {!permission?.granted ? (
-
- Serve l'accesso alla fotocamera.
- requestPermission()}>
- Chiedi permesso
-
- close(null)}>
- Annulla
-
-
- ) : (
- <>
- {
- cameraRef.current = ref as unknown as CameraRef | null;
- }}
- style={styles.camera}
- facing="back"
- />
-
- close(null)} disabled={busy}>
- Chiudi
-
-
- {busy ? : Scatta}
-
-
- >
- )}
+
+ Apri la fotocamera di sistema per scattare una foto.
+
+ {busy ? : Scatta foto}
+
+ close(null)} disabled={busy}>
+ Annulla
+
+
);
@@ -92,28 +83,17 @@ export function CameraCaptureModalHost() {
const styles = StyleSheet.create({
wrap: { flex: 1, backgroundColor: '#000', paddingTop: 48 },
title: { color: '#fff', fontSize: 18, textAlign: 'center', marginBottom: 12 },
- camera: { flex: 1, borderRadius: 12, overflow: 'hidden', marginHorizontal: 12 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
msg: { color: '#fff', textAlign: 'center', marginBottom: 16 },
- btn: { backgroundColor: '#2563eb', paddingHorizontal: 20, paddingVertical: 12, borderRadius: 8 },
- btnText: { color: '#fff', fontWeight: '600' },
secondary: { marginTop: 16 },
secondaryText: { color: '#fff' },
- actions: { flexDirection: 'row', gap: 12, padding: 24 },
- cancel: {
- flex: 1,
- padding: 14,
- backgroundColor: '#374151',
- borderRadius: 8,
- alignItems: 'center',
- },
- cancelText: { color: '#fff', fontWeight: '600' },
shutter: {
- flex: 1,
+ minWidth: 180,
padding: 14,
backgroundColor: '#fff',
borderRadius: 8,
alignItems: 'center',
},
+ shutterDisabled: { opacity: 0.6 },
shutterText: { color: '#111827', fontWeight: '700' },
});
diff --git a/src/renderer/DynamicRenderer.tsx b/src/renderer/DynamicRenderer.tsx
index 95a23ed..c8897b2 100644
--- a/src/renderer/DynamicRenderer.tsx
+++ b/src/renderer/DynamicRenderer.tsx
@@ -8,6 +8,8 @@ import { runAction, tryCompileModuleActions, type CompiledActions } from '../mod
import { renderNode, resolveTheme, type ResolvedTheme } from './components';
import { useI18n } from '../i18n/useI18n';
+type TimerUiNode = Extract;
+
function hasWebGameNode(node: UiNode): boolean {
if (node.type === 'webGame') return true;
if (node.type === 'navigator') return Object.values(node.screens).some((s) => s.components.some(hasWebGameNode));
@@ -31,6 +33,9 @@ function collectButtonActions(node: UiNode): string[] {
if (node.type === 'gamepad') {
return node.buttons.map((b) => b.action).filter(Boolean);
}
+ if (node.type === 'timer') {
+ return node.tickAction ? [node.tickAction] : [];
+ }
if (node.type === 'gameView') {
const acts: string[] = [];
if (node.tickAction) acts.push(node.tickAction);
@@ -68,6 +73,9 @@ function collectInitialState(node: UiNode, acc: Record) {
case 'image':
if (!(node.bind in acc)) acc[node.bind] = '';
break;
+ case 'timer':
+ if (node.activeBind && !(node.activeBind in acc)) acc[node.activeBind] = node.autoStart === true;
+ break;
case 'card':
case 'box':
node.components.forEach((c: UiNode) => collectInitialState(c, acc));
@@ -89,13 +97,36 @@ function buildInitialState(ui: UiNode): Record {
return acc;
}
+function collectTimers(node: UiNode): TimerUiNode[] {
+ if (node.type === 'timer') return [node];
+ if (node.type === 'navigator') return [];
+ if ('components' in node && Array.isArray(node.components)) return node.components.flatMap(collectTimers);
+ return [];
+}
+
+function timerSignature(timers: TimerUiNode[]): string {
+ return timers
+ .map((timer) =>
+ [
+ timer.id ?? '',
+ timer.tickAction,
+ timer.intervalMs ?? '',
+ timer.runImmediately === true ? '1' : '0',
+ timer.autoStart === false ? '0' : '1',
+ timer.activeBind ?? '',
+ ].join(':')
+ )
+ .join('|');
+}
+
type Props = {
ui: UiNode;
code: string;
motherApi: MotherApi;
+ webGameAllowNetwork?: boolean;
};
-export function DynamicRenderer({ ui, code, motherApi }: Props) {
+export function DynamicRenderer({ ui, code, motherApi, webGameAllowNetwork = false }: Props) {
const { t } = useI18n();
const isWebGame = useMemo(() => hasWebGameNode(ui) || WEBGAME_CODE_RE.test(code), [ui, code]);
@@ -204,23 +235,6 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentScreenKey]);
- if (compileError) {
- return (
-
- {t.rendererError}
- {compileError}
-
- );
- }
-
- if (ui.type !== 'screen' && ui.type !== 'navigator') {
- return (
-
- {t.rendererRootError}
-
- );
- }
-
// ── Resolve theme ──
let activeScreen: NavigatorScreen | null = null;
let theme: ResolvedTheme;
@@ -235,7 +249,17 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) {
theme = resolveTheme();
}
- const ctx = { state, setState: patchState, onButton, onNavigate, busyAction, theme, hasError: Boolean(error), webgameCode: isWebGame ? code : undefined };
+ const ctx = {
+ state,
+ setState: patchState,
+ onButton,
+ onNavigate,
+ busyAction,
+ theme,
+ hasError: Boolean(error),
+ webgameCode: isWebGame ? code : undefined,
+ webGameAllowNetwork,
+ };
// ── Which node to render ──
const nodeToRender: UiNode | null =
@@ -245,6 +269,91 @@ export function DynamicRenderer({ ui, code, motherApi }: Props) {
? ui
: null;
+ const timers = useMemo(() => (nodeToRender ? collectTimers(nodeToRender) : []), [nodeToRender]);
+ const timerConfigKey = useMemo(() => timerSignature(timers), [timers]);
+ const activeTimerKey = useMemo(
+ () =>
+ timers
+ .map((timer) =>
+ timer.activeBind ? `${timer.id ?? timer.tickAction}:${String(Boolean(state[timer.activeBind]))}` : ''
+ )
+ .join('|'),
+ [timers, state]
+ );
+
+ useEffect(() => {
+ if (compileError || isWebGame || !actions || timers.length === 0) return;
+
+ const stops: Array<() => void> = [];
+ timers.forEach((timer) => {
+ if (!timer.tickAction || !(timer.tickAction in actions)) return;
+
+ const active =
+ timer.activeBind != null
+ ? Boolean(stateRef.current[timer.activeBind])
+ : timer.autoStart !== false;
+ if (!active) return;
+
+ const intervalMs = Math.max(100, Math.round(Number(timer.intervalMs) || 1000));
+ const timerId = timer.id ?? timer.tickAction;
+ let cancelled = false;
+ let busy = false;
+
+ const tick = () => {
+ if (cancelled || busy) return;
+ busy = true;
+ runAction(
+ actions,
+ timer.tickAction,
+ motherApi,
+ { timerId, tick: true, intervalMs, now: Date.now() },
+ stateRef.current,
+ { timeoutMs: Math.max(1000, Math.min(8000, intervalMs)) }
+ )
+ .then((delta) => {
+ if (cancelled) return;
+ if (delta && typeof delta === 'object') patchState(delta as Record);
+ })
+ .catch((e) => {
+ if (cancelled) return;
+ setError('Timer non riuscito, rigenera il modulo.');
+ reportGenAppError('DynamicRenderer.timer', e, { action: timer.tickAction, timerId });
+ })
+ .finally(() => {
+ busy = false;
+ });
+ };
+
+ if (timer.runImmediately) tick();
+ const id = setInterval(tick, intervalMs);
+ stops.push(() => {
+ cancelled = true;
+ clearInterval(id);
+ });
+ });
+
+ return () => {
+ stops.forEach((stop) => stop());
+ };
+ }, [actions, activeTimerKey, compileError, isWebGame, motherApi, patchState, timerConfigKey, timers]);
+
+ if (compileError) {
+ return (
+
+ {t.rendererError}
+ {compileError}
+
+ );
+ }
+
+ if (ui.type !== 'screen' && ui.type !== 'navigator') {
+ return (
+
+ {t.rendererRootError}
+
+ );
+ }
+
return (
{/* Back button for navigator */}
diff --git a/src/renderer/components.tsx b/src/renderer/components.tsx
index 7fe7504..a3a683c 100644
--- a/src/renderer/components.tsx
+++ b/src/renderer/components.tsx
@@ -121,6 +121,8 @@ export type RenderCtx = {
hasError: boolean;
/** Codice raw del modulo webGame (browser JS con canvas 2D) */
webgameCode?: string;
+ /** Enables browser-side network APIs for webGame only after app-level permission checks. */
+ webGameAllowNetwork?: boolean;
/** Ref al WebView per inviare eventi gamepad */
webViewRef?: React.RefObject;
};
@@ -748,9 +750,212 @@ function GameViewNode({
// WebGameNode — canvas 2D inside WebView at true 60fps, no sandbox overhead
// ─────────────────────────────────────────────────────────────────────────────
-function buildWebGameHtml(code: string, width: number, height: number): string {
+function buildWebGameHtml(code: string, width: number, height: number, allowNetwork: boolean): string {
// Only escape to prevent premature script tag closing
const safeCode = code.replace(/<\/script>/gi, '<\\/script>');
+ const gameKitRuntime = `
+/* GameKit: small host-provided physics + levels helper for generated canvas games. */
+(function(){
+ function clamp(v,min,max){return Math.max(min,Math.min(max,v));}
+ function copy(src){var o={},k;if(!src)return o;for(k in src){if(Object.prototype.hasOwnProperty.call(src,k))o[k]=src[k];}return o;}
+ function bounds(b){
+ var w=b.w||((b.r||0)*2)||0,h=b.h||((b.r||0)*2)||0,x=b.x||0,y=b.y||0;
+ var inset=Number(b.hitboxInset||0),scale=b.hitboxScale==null?1:Number(b.hitboxScale);
+ if(scale>0&&scale<1){var nw=w*scale,nh=h*scale;x+=(w-nw)/2;y+=(h-nh)/2;w=nw;h=nh;}
+ if(inset>0){x+=inset;y+=inset;w=Math.max(1,w-inset*2);h=Math.max(1,h-inset*2);}
+ return{x:x,y:y,w:w,h:h};
+ }
+ function rectsOverlap(a,b){return a.xb.x&&a.yb.y;}
+ function circleRectOverlap(c,r){
+ var radius=(c.hitR||c.r||Math.min(c.w||20,c.h||20)/2)*(c.hitboxScale==null?1:Number(c.hitboxScale));
+ var cx=(c.x||0)+(c.r||((c.w||20)/2)),cy=(c.y||0)+(c.r||((c.h||20)/2));
+ var rb=bounds(r),nx=clamp(cx,rb.x,rb.x+rb.w),ny=clamp(cy,rb.y,rb.y+rb.h),dx=cx-nx,dy=cy-ny;
+ return dx*dx+dy*dy<=radius*radius;
+ }
+ function bodiesOverlap(a,b){
+ if(a.type==='circle'&&b.type!=='circle')return circleRectOverlap(a,b);
+ if(b.type==='circle'&&a.type!=='circle')return circleRectOverlap(b,a);
+ return rectsOverlap(bounds(a),bounds(b));
+ }
+ function centerX(b){var bb=bounds(b);return bb.x+bb.w/2;}
+ function centerY(b){var bb=bounds(b);return bb.y+bb.h/2;}
+ function drawBody(ctx,b){
+ ctx.fillStyle=b.color||'#fff';
+ if(b.type==='circle'){ctx.beginPath();ctx.arc((b.x||0)+(b.r||0),(b.y||0)+(b.r||0),b.r||8,0,Math.PI*2);ctx.fill();return;}
+ ctx.fillRect(b.x||0,b.y||0,b.w||20,b.h||20);
+ }
+ function drawBackdrop(ctx,w,h,bg){
+ var g=ctx.createLinearGradient(0,0,0,h);
+ g.addColorStop(0,bg||'#0f172a');g.addColorStop(1,'#020617');
+ ctx.fillStyle=g;ctx.fillRect(0,0,w,h);
+ ctx.globalAlpha=0.28;ctx.fillStyle='#38bdf8';
+ for(var i=0;i<5;i++){ctx.beginPath();ctx.arc((i*91+40)%w,70+i*38,18+i*4,0,Math.PI*2);ctx.fill();}
+ ctx.globalAlpha=1;
+ }
+ function roundRect(ctx,x,y,w,h,r){
+ r=Math.min(r||8,w/2,h/2);ctx.beginPath();ctx.moveTo(x+r,y);ctx.lineTo(x+w-r,y);ctx.quadraticCurveTo(x+w,y,x+w,y+r);ctx.lineTo(x+w,y+h-r);ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);ctx.lineTo(x+r,y+h);ctx.quadraticCurveTo(x,y+h,x,y+h-r);ctx.lineTo(x,y+r);ctx.quadraticCurveTo(x,y,x+r,y);ctx.closePath();
+ }
+ function drawTextPill(ctx,text,x,y,w){
+ ctx.fillStyle='rgba(15,23,42,0.72)';roundRect(ctx,x,y,w,32,10);ctx.fill();
+ ctx.fillStyle='#e5e7eb';ctx.font='bold 14px sans-serif';ctx.textAlign='left';ctx.fillText(text,x+10,y+21);
+ }
+ function create(opts){
+ opts=opts||{};
+ var world={
+ width:opts.width||window.WIDTH||360,
+ height:opts.height||window.HEIGHT||600,
+ gravityY:opts.gravityY==null?0.65:Number(opts.gravityY),
+ bodies:[],
+ levels:[],
+ levelIndex:0,
+ levelName:'',
+ completed:false,
+ status:'playing',
+ message:'',
+ transition:1,
+ particles:[],
+ shake:0,
+ combo:0,
+ background:opts.background||'#0f172a',
+ score:0,
+ input:{left:false,right:false,up:false,down:false,jump:false,fire:false},
+ clear:function(){this.bodies=[];this.completed=false;this.status='playing';},
+ add:function(body){
+ var b=copy(body);
+ b.id=b.id||('body'+this.bodies.length);
+ b.type=b.type||'rect';
+ b.x=Number(b.x||0);b.y=Number(b.y||0);
+ b.w=Number(b.w||(b.r?b.r*2:24));b.h=Number(b.h||(b.r?b.r*2:24));
+ b.vx=Number(b.vx||0);b.vy=Number(b.vy||0);
+ b.bounce=Number(b.bounce||0);b.friction=b.friction==null?0.82:Number(b.friction);
+ b.gravityScale=b.gravityScale==null?1:Number(b.gravityScale);
+ b.static=!!b.static;b.solid=b.solid!==false;b.sensor=!!b.sensor;
+ b.opacity=b.opacity==null?1:Number(b.opacity);
+ this.bodies.push(b);return b;
+ },
+ get:function(id){for(var i=0;i=this.levels.length-1){this.completed=true;this.status='completed';this.message='Completato';this.emit(this.width/2,this.height/2,'#facc15',34);return false;}
+ return this.loadLevel(this.levelIndex+1);
+ },
+ restartLevel:function(){return this.loadLevel(this.levelIndex);},
+ fail:function(msg){if(this.status!=='playing')return;this.status='failed';this.message=msg||'Riprova';this.shakeScreen(10);},
+ completeLevel:function(msg){var self=this;if(this.status!=='playing')return;this.status='levelComplete';this.message=msg||'Livello completato';this.emit(this.width/2,this.height/2,'#22c55e',26);this.shakeScreen(5);setTimeout(function(){if(self.status==='levelComplete')self.nextLevel();},650);},
+ shakeScreen:function(power){this.shake=Math.max(this.shake,Number(power||6));},
+ emit:function(x,y,color,count){
+ count=count||16;color=color||'#fff';
+ for(var i=0;ithis.height+180&&a.killBelow!==false){a.dead=true;}
+ }
+ for(i=0;i0;});
+ this.bodies=this.bodies.filter(function(b){return !b.dead;});
+ },
+ draw:function(ctx){
+ var sx=this.shake?((Math.random()*2-1)*this.shake):0,sy=this.shake?((Math.random()*2-1)*this.shake):0;
+ ctx.save();ctx.translate(sx,sy);drawBackdrop(ctx,this.width,this.height,this.background);
+ for(var i=0;i0){ctx.fillStyle='rgba(0,0,0,'+(this.transition*0.45)+')';ctx.fillRect(0,0,this.width,this.height);ctx.fillStyle='rgba(255,255,255,'+this.transition+')';ctx.font='bold 30px sans-serif';ctx.textAlign='center';ctx.fillText(this.message||this.levelName,this.width/2,this.height/2);}
+ if(this.status==='failed'||this.status==='completed'){ctx.fillStyle='rgba(0,0,0,0.62)';ctx.fillRect(0,0,this.width,this.height);ctx.fillStyle='#fff';ctx.font='bold 30px sans-serif';ctx.textAlign='center';ctx.fillText(this.message,this.width/2,this.height/2-12);ctx.font='15px sans-serif';ctx.fillStyle='#cbd5e1';ctx.fillText(this.status==='failed'?'tocca per riprovare':'partita completata',this.width/2,this.height/2+24);}
+ }
+ };
+ return world;
+ }
+ function bindPlatformControls(world,playerId,opts){
+ opts=opts||{};var jumpPower=opts.jumpPower||12,speed=opts.speed||3.2;
+ function setTouches(e){var i,x,y;world.input.left=false;world.input.right=false;world.input.jump=false;for(i=0;iworld.width*0.67)world.input.right=true;if(yworld.width*0.58)world.input.jump=true;}}
+ canvas.addEventListener('touchstart',function(e){e.preventDefault();setTouches(e);},{passive:false});
+ canvas.addEventListener('touchmove',function(e){e.preventDefault();setTouches(e);},{passive:false});
+ canvas.addEventListener('touchend',function(e){e.preventDefault();setTouches(e);},{passive:false});
+ return function(){
+ var p=world.get(playerId||'player');if(!p)return;
+ p.vx=(world.input.right?speed:0)-(world.input.left?speed:0);
+ if(world.input.jump&&p.onGround){p.vy=-jumpPower;p.onGround=false;}
+ };
+ }
+ function startPlatformer(opts){
+ opts=opts||{};var game=create(opts);game.setLevels(opts.levels||[]);var playerId=opts.playerId||'player';var controls=bindPlatformControls(game,playerId,opts.controls||{});
+ canvas.addEventListener('touchstart',function(){if(game.status==='failed')game.restartLevel();},{passive:false});
+ function frame(){var p=game.get(playerId);if(game.status==='playing'){controls();if(opts.update)opts.update(game,p);game.step(1/60);p=game.get(playerId);if(p&&p.dead)game.fail('Game over');if(p&&game.touchesTag(p,'goal'))game.completeLevel();}game.draw(ctx);if(opts.draw)opts.draw(game,ctx);requestAnimationFrame(frame);}
+ requestAnimationFrame(frame);return game;
+ }
+ __def('GameKit',{create:create,bindPlatformControls:bindPlatformControls,startPlatformer:startPlatformer});
+})();`;
+ const networkGuard = allowNetwork
+ ? ''
+ : `
+/* Network is denied unless the module declares "network", the user grants it,
+ and the host build enables module network access. */
+(function(){
+ function denied(){throw new Error('Network APIs are disabled for this webGame module.');}
+ try{Object.defineProperty(window,'fetch',{value:denied,writable:false,configurable:false});}catch(e){window.fetch=denied;}
+ try{Object.defineProperty(window,'XMLHttpRequest',{value:function(){denied();},writable:false,configurable:false});}catch(e){window.XMLHttpRequest=function(){denied();};}
+ try{Object.defineProperty(window,'WebSocket',{value:function(){denied();},writable:false,configurable:false});}catch(e){window.WebSocket=function(){denied();};}
+ try{Object.defineProperty(window,'EventSource',{value:function(){denied();},writable:false,configurable:false});}catch(e){window.EventSource=function(){denied();};}
+ if(window.navigator){try{Object.defineProperty(window.navigator,'sendBeacon',{value:function(){denied();},writable:false,configurable:false});}catch(e){window.navigator.sendBeacon=function(){denied();};}}
+ try{Object.defineProperty(window,'importScripts',{value:denied,writable:false,configurable:false});}catch(e){window.importScripts=denied;}
+})();`;
return `
@@ -778,6 +983,8 @@ __def('ctx',__ctx);
__def('WIDTH',__W);
__def('HEIGHT',__H);
__def('sendState',function(p){try{window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'state',patch:p}));}catch(e){}});
+${networkGuard}
+${gameKitRuntime}
/* Gamepad bridge: receive button events from React Native */
function __onMsg(e){try{var d=JSON.parse(e.data);if(d&&d.type==='btn'&&typeof window.__onBtn==='function')window.__onBtn(d.id,d.on);}catch(ex){}}
@@ -817,7 +1024,8 @@ function WebGameNode({
const gw = node.width ?? 360;
const gh = node.height ?? 600;
const code = ctx.webgameCode ?? '// no game code';
- const html = useMemo(() => buildWebGameHtml(code, gw, gh), [code, gw, gh]);
+ const allowNetwork = ctx.webGameAllowNetwork === true;
+ const html = useMemo(() => buildWebGameHtml(code, gw, gh, allowNetwork), [code, gw, gh, allowNetwork]);
const onMessage = useCallback((event: WebViewMessageEvent) => {
try {
@@ -847,7 +1055,8 @@ function WebGameNode({
bounces={false}
javaScriptEnabled
domStorageEnabled
- originWhitelist={['*']}
+ originWhitelist={allowNetwork ? ['*'] : ['about:blank']}
+ onShouldStartLoadWithRequest={(request) => allowNetwork || request.url === 'about:blank'}
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
onMessage={onMessage}
@@ -993,15 +1202,10 @@ export function renderNode(node: UiNode, ctx: RenderCtx, keyPrefix: string): Rea
);
const onPress = () => {
- console.log('[Button] pressed', { id: node.id, navigate: node.navigate, action: actionName });
if (node.navigate) {
- console.log('[Button] calling onNavigate →', node.navigate);
ctx.onNavigate(node.navigate);
} else if (actionName) {
- console.log('[Button] calling onButton →', actionName);
void ctx.onButton(actionName, node.actionInput ?? {});
- } else {
- console.log('[Button] no navigate and no action — nothing to do');
}
};
@@ -1123,7 +1327,14 @@ export function renderNode(node: UiNode, ctx: RenderCtx, keyPrefix: string): Rea
);
case 'image': {
- const uri = getBound(ctx.state, node.bind);
+ const raw = ctx.state[node.bind];
+ // Support both string URI and object with { uri } (e.g. full camera result stored in state)
+ const uri =
+ typeof raw === 'string'
+ ? raw
+ : raw && typeof raw === 'object' && typeof (raw as Record).uri === 'string'
+ ? ((raw as Record).uri as string)
+ : '';
if (!uri) return Nessuna immagine;
return (
console.warn('[Image] load error for', uri, e.nativeEvent.error)}
/>
);
}
@@ -1160,6 +1372,9 @@ export function renderNode(node: UiNode, ctx: RenderCtx, keyPrefix: string): Rea
);
+ case 'timer':
+ return null;
+
case 'webGame':
return ;
diff --git a/src/security/runtimePermissions.ts b/src/security/runtimePermissions.ts
index 8c30360..0406d22 100644
--- a/src/security/runtimePermissions.ts
+++ b/src/security/runtimePermissions.ts
@@ -1,5 +1,5 @@
import { Platform } from 'react-native';
-import { Audio } from 'expo-av';
+import { requestRecordingPermissionsAsync } from 'expo-audio';
import { Camera } from 'expo-camera';
import * as Location from 'expo-location';
import type { MotherPermission } from '../types/generatedModule';
@@ -14,7 +14,7 @@ export async function prefetchNativePermissions(perms: MotherPermission[]): Prom
if (set.has('audioRecorder')) {
try {
- const r = await Audio.requestPermissionsAsync();
+ const r = await requestRecordingPermissionsAsync();
if (!r.granted) {
warnings.push('Microfono: permesso non concesso (il registratore potrebbe fallire).');
}
diff --git a/src/types/generatedModule.ts b/src/types/generatedModule.ts
index 823dd06..7ec4e6b 100644
--- a/src/types/generatedModule.ts
+++ b/src/types/generatedModule.ts
@@ -200,6 +200,17 @@ export const uiNodeSchema: ZodType = z.lazy(() => {
hint: z.string().optional(),
})
.extend({ layout: uiLayoutPropsSchema.optional() }),
+ z
+ .object({
+ type: z.literal('timer'),
+ id: z.string().optional(),
+ tickAction: z.string(),
+ intervalMs: z.number().optional(),
+ runImmediately: z.boolean().optional(),
+ activeBind: z.string().optional(),
+ autoStart: z.boolean().optional(),
+ })
+ .extend({ layout: uiLayoutPropsSchema.optional() }),
z
.object({
type: z.literal('webGame'),
diff --git a/src/types/uiNodes.ts b/src/types/uiNodes.ts
index a4ec222..85a06fc 100644
--- a/src/types/uiNodes.ts
+++ b/src/types/uiNodes.ts
@@ -118,6 +118,21 @@ export type UiNode =
| { type: 'image'; id?: string; bind: string; height?: number; layout?: UiLayoutProps }
| { type: 'audioRecorder'; id?: string; statusBind?: string; layout?: UiLayoutProps }
| { type: 'qrScanner'; id?: string; hint?: string; layout?: UiLayoutProps }
+ | {
+ /** Ticker gestito dall'host: chiama tickAction a intervalli regolari. Non renderizza UI visibile. */
+ type: 'timer';
+ id?: string;
+ tickAction: string;
+ /** Millisecondi tra un tick e l'altro. Default 1000, minimo 100. */
+ intervalMs?: number;
+ /** Se true, esegue un tick appena il timer diventa attivo. */
+ runImmediately?: boolean;
+ /** Se presente, il timer gira solo quando state[activeBind] è truthy. */
+ activeBind?: string;
+ /** Senza activeBind default true; con activeBind inizializza quel bind a true solo se autoStart è true. */
+ autoStart?: boolean;
+ layout?: UiLayoutProps;
+ }
| {
/** Motore gioco WebView — canvas 2D + requestAnimationFrame 60fps nativi */
type: 'webGame';