La tua app sul telefono
Un esempio funzionante si trova in example/mic_phone_demo. Copia la cartella, cambia i nomi e hai la tua app.
my_app/ fxmanifest.lua client.lua registers the app server.lua anything that touches money or the database ui/index.html your page ui/icon.svg your icon-- fxmanifest.luafx_version 'cerulean'game 'gta5'
client_script 'client.lua'server_script 'server.lua'
files { -- WITHOUT THIS your page and icon are not served and the frame comes up blank 'ui/index.html', 'ui/icon.svg',}Il blocco files è l’unica cosa che tutti dimenticano. FiveM serve solo ciò che una risorsa elenca lì dentro, quindi una pagina non inclusa risponde 404 e la tua app si apre mostrando una schermata bianca.
local function register() exports.mic_phone:AddApp({ id = 'phonedemo', name = 'Field Notes', icon = ('https://cfx-nui-%s/ui/icon.svg'):format(GetCurrentResourceName()), ui = ('%s/ui/index.html'):format(GetCurrentResourceName()), })end
AddEventHandler('mic_phone:ready', register) -- fires on start, and after every restart of the phoneQuesto è l’intero contratto, non serve altro: non c’è nulla da configurare sul telefono e nulla da riavviare. Avvia la tua risorsa e l’app compare sulla schermata Home — default = true la mette lì, default = false la mette invece nell’App Store. In entrambi i casi la decisione è tua, dentro il tuo file.
Config.AppList.Custom esiste affinché il proprietario del server possa avere l’ultima parola: dare un prezzo alla tua app, distribuirla preinstallata o disattivarla. Non è mai necessario toccarlo perché la tua app funzioni.
Quattro cose che non devi fare, e che gli altri telefoni ti costringono a fare:
- Non devi rimuovere l’app prima di aggiungerla. Registrare lo stesso ID dalla stessa risorsa la aggiorna. Solo una risorsa diversa che tenta di reclamare un ID già appartenente a qualcun altro viene rifiutata, con tanto di spiegazione.
- Non devi aspettare il telefono.
mic_phone:readyscatta quando il telefono è pronto ad accettare app e di nuovo a ogni suo riavvio, quindi la tua app torna da sola dopo unrestart mic_phone. - Non devi fare pulizia quando la tua risorsa si ferma. Le tue app spariscono insieme a lei. Uno script fermato non lascia mai dietro di sé un’icona morta che non fa nulla.
- Non devi riavviare il telefono. Un’app registrata mentre qualcuno ha già il telefono in mano compare sullo schermo in quello stesso istante.
Cosa può dichiarare un’app su se stessa
Sezione intitolata “Cosa può dichiarare un’app su se stessa”id | lettere, numeri, _ e -. Univoco su tutto il server |
name | il testo sotto l’icona |
subtitle, description, features, version | la scheda dell’App Store |
developer, category | mostrati nell’App Store |
color | lo sfondo dell’icona quando non c’è icon |
icon | un URL. https://cfx-nui-<resource>/path.png lo serve direttamente dalla tua cartella |
ui | resource/path/index.html, la pagina della tua app. Omettilo per un’app che esegue solo Lua. Deve essere un percorso interno a una risorsa su questo server, mai un URL esterno — vedi sotto |
landscape | la pagina richiede il telefono aperto/orizzontale |
size, images | ciò che mostra la pagina dell’App Store. Le immagini devono iniziare con https:// |
default | true la posiziona direttamente sulla schermata Home invece che nell’App Store |
onOpen, onClose | chiamate quando l’app si apre e quando si chiude |
Non puoi decidere tu il prezzo. Non esiste alcun campo price, ed è una scelta voluta: quel numero vivrebbe sul client, e un prezzo dichiarato dal client è un prezzo che il client può modificare — l’App Store mostrerebbe una cifra e il server non addebiterebbe nulla. I prezzi si impostano in Config.AppList.Custom, scritto dal proprietario del server e letto dal server. Se vuoi che la tua app abbia un costo in gioco, chiedi di aggiungere una riga lì; ne basta una.
La città ha l’ultima parola. Qualsiasi cosa tu specifichi qui è una richiesta. Config.AppList.Custom nella configurazione del telefono può dare un prezzo alla tua app, renderla preinstallata o disattivarla del tutto, senza che nessuno debba modificare la tua risorsa. È fatto apposta: chi gestisce un server non dovrebbe dover modificare uno script per far pagare un’app in gioco.
Comunicare con la tua pagina
Sezione intitolata “Comunicare con la tua pagina”La tua pagina è un iframe. Non può accedere all’interno del telefono, leggere i messaggi o disegnare fuori dal proprio rettangolo; né il telefono può entrare nella tua pagina. Tutto passa attraverso i messaggi.
ui deve essere un percorso interno a una risorsa su questo server. Un frame richiede che la Content-Security-Policy (CSP) della pagina ne autorizzi l’origine, e la CSP non ha modo di esprimere “qualsiasi risorsa cfx-nui-” — il carattere jolly è ammesso solo come intera etichetta più a sinistra, quindi non si può scrivere https://cfx-nui-* e la direttiva deve essere aperta a https: affinché il frame possa caricarsi. Essendo un permesso ampio, il telefono lo restringe dall’altro lato: costruisce l’origine a partire dal percorso della tua risorsa e rifiuta qualsiasi cosa sia già un URL completo. Se vuoi servire la tua pagina da qualche altra parte, servila dalla tua cartella.
Nella tua pagina, due righe:
parent.postMessage({ type: 'phone:ready' }, '*'); // say you have loadedconst send = m => parent.postMessage({ type: 'phone:message', message: m }, '*');e rimani in ascolto:
window.addEventListener('message', e => { if (e.data?.type === 'phone:theme') document.documentElement.dataset.theme = e.data.theme; if (e.data?.type === 'phone:message') { /* from your Lua */ }});Nel tuo Lua:
exports.mic_phone:SendAppMessage('phonedemo', { kind = 'hello' })
AddEventHandler(GetCurrentResourceName() .. ':phone:message', function(message) -- from your page. Routed here by the resource that owns the app, so nothing else can send theseend)Invia pure ciò che vuoi da onOpen. Un messaggio destinato a una pagina non ancora caricata rimane in attesa e arriva in ordine nell’istante in cui la pagina invia phone:ready. Gli altri telefoni ti avvertono di non farlo perché il loro messaggio fa a gara con il caricamento dell’iframe; qui non succede, perché inviare dati all’apertura dell’app è la cosa più ovvia da fare, e le cose ovvie devono funzionare.
phone:theme arriva con il tema light/dark del telefono e la sua lingua non appena la tua pagina saluta. Seguilo e la tua app smetterà di sembrare un sito web incollato sopra con lo scotch.
Chiedere qualcosa al telefono
Sezione intitolata “Chiedere qualcosa al telefono”La tua pagina può chiedere al telefono di fare cose che sa già fare: mostrare una notifica, aprire la fotocamera, far scegliere una foto o un contatto. Quindici righe preparano il tutto, dopodiché ogni richiesta è un semplice await:
let next = 1; const waiting = new Map();const ask = (what, args) => new Promise(resolve => { const id = next++; waiting.set(id, resolve); parent.postMessage({ type: 'phone:ask', id, what, with: args || {} }, '*');});window.addEventListener('message', e => { if (e.data?.type === 'phone:answer') { const done = waiting.get(e.data.id); if (done) { waiting.delete(e.data.id); done(e.data.value); } }});const me = await ask('me'); // { number, name }const photo = await ask('photos.pick'); // { id, url, width, height } or nullconst shot = await ask('camera.take'); // the same, from the real Cameraconst person = await ask('contacts.pick'); // { name, number } or nullawait ask('notify', { title: 'Field Notes', body: 'Something happened.' });await ask('toast', { text: 'Saved.' });const look = await ask('theme'); // { theme, language, tablet }await ask('close'); // put yourself away| Richiesta | Cosa restituisce | |
|---|---|---|
me | { number, name } | ciò che gli altri telefoni vedono già, e nient’altro sulla persona |
notify | true | archiviata sotto la tua app, quindi silenziarla in Impostazioni ti silenzia |
toast | true | una riga di avviso in fondo allo schermo |
photos.pick | una foto, oppure null | apre la libreria del telefono; null significa che è stata chiusa |
camera.take | una foto, oppure null | apre la Fotocamera vera, attende lo scatto e ti riporta indietro |
contacts.pick | un contatto, oppure null | apre un elenco di nomi |
theme | { theme, language, tablet } | gli stessi valori inviati da phone:theme |
close | true | torna alla schermata Home |
E l’altra metà, che restituisce qualcosa al telefono:
await ask('message.compose', { number: '555-0110', text: 'Your order is ready.' });await ask('call.start', { number: '555-0110' });await ask('share.text', { text: 'Look at this.' }); // → { to, number } or nullawait ask('share.photo', { url: photo.url });await ask('photos.save', { url: someImageUrl }); // → { id, width, height } or nullawait ask('waypoint.set', { x: 219, y: -855 });const money = await ask('money.balance'); // { balance }| Richiesta | Cosa restituisce | |
|---|---|---|
message.compose | true | apre Messaggi con il testo già scritto nel campo. È un umano a premere invia |
call.start | true | fa squillare il numero |
share.text / share.photo | { to, number }, o null | apre un selettore, poi prepara il messaggio per chi è stato scelto |
photos.save | { id, width, height } | salva in Foto un’immagine creata dalla tua app, come uno scatto della fotocamera |
waypoint.set | true | imposta un segnalino sulla mappa |
money.balance | { balance } | sola lettura — il denaro si sposta sul server, vedi sotto |
Nulla viene inviato a nome di qualcuno senza che l’interessato lo veda. message.compose riempie la casella di testo e lascia il pulsante di invio al giocatore; la condivisione chiede ogni volta a chi è destinata. Un’app capace di mandare messaggi di nascosto ai tuoi contatti sarebbe molto peggio di un’app che non può mandare messaggi affatto.
Il denaro si sposta sul server, dal tuo file lato server, perché un prezzo su cui il client può discutere non è un prezzo. Ci sono tre porte, e quale usare dipende da chi ha spostato i soldi:
-- somebody else already took the money; this only writes the line in the Walletexports.mic_phone:AddTransaction(src, { title = 'Tequi-la-la', detail = 'Two drinks', amount = -40 })
-- take it, and write the line. false, 'funds' and nothing moved when they cannot paylocal ok, tx = exports.mic_phone:Charge(src, 250, { title = 'Field Notes', detail = 'Pro upgrade' })
-- give it, and write the lineexports.mic_phone:Pay(src, 1000, { title = 'Delivery', detail = 'Job finished' })
exports.mic_phone:GetBalance(src)Tutte e tre aggiornano immediatamente il Portafoglio a schermo. Charge e Pay falliscono del tutto o riescono del tutto: un addebito rifiutato lascia il saldo e la cronologia esattamente com’erano.
Due regole reggono questo sistema, e spiegano perché l’elenco sia essenziale anziché permissivo:
La tua app non riceve mai dati che nessuno le ha consegnato. contacts.pick non restituisce l’intera rubrica: apre una schermata, il giocatore tocca un nome e solo quel nome viene restituito. Chiederlo in un ciclo non legge il telefono; ogni risposta richiede un tocco umano. Lo stesso vale per le foto.
Solo l’app attualmente a schermo può fare richieste. Una domanda proveniente da un frame che non è l’app aperta non riceve risposta e non apre nulla, così una pagina lasciata attiva in sottofondo non può agire mentre il giocatore sta facendo altro.
Se ciò che ti serve non è in quell’elenco, non è perché il telefono si rifiuti, ma solo perché nessuno lo ha ancora aggiunto. Aggiungerlo richiede una voce in PHONE_APP_ASKS dentro html/apps/store/app-api.js e nient’altro.
Chiedere informazioni sul telefono
Sezione intitolata “Chiedere informazioni sul telefono”exports.mic_phone:RegisteredApps() --> every app registered on this client, and by whomexports.mic_phone:IsAppOpen('phonedemo') --> is it on screenexports.mic_phone:OpenAppId() --> which custom app is, or nilexports.mic_phone:OpenApp('phonedemo') --> open it, if the phone will open at allRegisteredApps è la funzione da chiamare quando la tua app non compare. Il comando phoneapps sul server stampa ciò che dice la configurazione, che è un elenco diverso: la tua app vive sul client che l’ha registrata, e compare in phoneapps solo se qualcuno le ha dedicato una voce in Config.AppList.Custom.
Un’app che non ha né default = true né installed nella configurazione resta nell’App Store finché qualcuno non la scarica. Se la tua risulta registrata ma non la trovi sulla schermata Home, di solito il motivo è questo.
Quando qualcosa non funziona
Sezione intitolata “Quando qualcosa non funziona”| Cosa vedi | Quasi sempre dipende da |
|---|---|
| L’app non c’è da nessuna parte | Controlla prima RegisteredApps(). Se non c’è, AddApp è stato rifiutato: il motivo è stampato nella console client (F8), non in quella del server |
| È registrata ma non è sulla schermata Home | Si trova nell’App Store: hai impostato default = false, oppure il proprietario ha messo How = 'store' in Config.AppList.Custom |
| L’app si apre su una pagina bianca | Il tuo file ui non è elencato nel blocco files del tuo fxmanifest.lua |
Refused to frame nella console | Il tuo ui è un URL. Deve essere un percorso interno a una risorsa su questo server |
| La pagina si carica, ma i messaggi non arrivano mai | La tua pagina non ha mai inviato phone:ready. Nulla viene consegnato finché non lo fa: i messaggi vengono trattenuti, non persi, e arrivano tutti insieme |
no answer in 12000ms | Hai chiamato qualcosa che il telefono non prevede. Controlla il nome rispetto all’elenco qui sopra |
phoneapps sul server stampa la configurazione, non la tua app: un’app registrata da una risorsa vive sul client che l’ha registrata e compare lì solo se qualcuno le ha aggiunto una voce in Config.AppList.Custom. exports.mic_phone:RegisteredApps() è l’elenco effettivamente presente su quella macchina.
Il resto delle API del telefono — state bag, chiamate, contatti, notifiche — si trova in Export e state bag.