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';