Salta ai contenuti

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.lua
fx_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 phone

Questo è 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:ready scatta quando il telefono è pronto ad accettare app e di nuovo a ogni suo riavvio, quindi la tua app torna da sola dopo un restart 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.
idlettere, numeri, _ e -. Univoco su tutto il server
nameil testo sotto l’icona
subtitle, description, features, versionla scheda dell’App Store
developer, categorymostrati nell’App Store
colorlo sfondo dell’icona quando non c’è icon
iconun URL. https://cfx-nui-<resource>/path.png lo serve direttamente dalla tua cartella
uiresource/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
landscapela pagina richiede il telefono aperto/orizzontale
size, imagesciò che mostra la pagina dell’App Store. Le immagini devono iniziare con https://
defaulttrue la posiziona direttamente sulla schermata Home invece che nell’App Store
onOpen, onClosechiamate 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.

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 loaded
const 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 these
end)

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.

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 null
const shot = await ask('camera.take'); // the same, from the real Camera
const person = await ask('contacts.pick'); // { name, number } or null
await 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
RichiestaCosa restituisce
me{ number, name }ciò che gli altri telefoni vedono già, e nient’altro sulla persona
notifytruearchiviata sotto la tua app, quindi silenziarla in Impostazioni ti silenzia
toasttrueuna riga di avviso in fondo allo schermo
photos.pickuna foto, oppure nullapre la libreria del telefono; null significa che è stata chiusa
camera.takeuna foto, oppure nullapre la Fotocamera vera, attende lo scatto e ti riporta indietro
contacts.pickun contatto, oppure nullapre un elenco di nomi
theme{ theme, language, tablet }gli stessi valori inviati da phone:theme
closetruetorna 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 null
await ask('share.photo', { url: photo.url });
await ask('photos.save', { url: someImageUrl }); // → { id, width, height } or null
await ask('waypoint.set', { x: 219, y: -855 });
const money = await ask('money.balance'); // { balance }
RichiestaCosa restituisce
message.composetrueapre Messaggi con il testo già scritto nel campo. È un umano a premere invia
call.starttruefa squillare il numero
share.text / share.photo{ to, number }, o nullapre 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.settrueimposta 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 Wallet
exports.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 pay
local ok, tx = exports.mic_phone:Charge(src, 250, { title = 'Field Notes', detail = 'Pro upgrade' })
-- give it, and write the line
exports.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.

exports.mic_phone:RegisteredApps() --> every app registered on this client, and by whom
exports.mic_phone:IsAppOpen('phonedemo') --> is it on screen
exports.mic_phone:OpenAppId() --> which custom app is, or nil
exports.mic_phone:OpenApp('phonedemo') --> open it, if the phone will open at all

RegisteredApps è 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.

Cosa vediQuasi sempre dipende da
L’app non c’è da nessuna parteControlla 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 HomeSi 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 biancaIl tuo file ui non è elencato nel blocco files del tuo fxmanifest.lua
Refused to frame nella consoleIl tuo ui è un URL. Deve essere un percorso interno a una risorsa su questo server
La pagina si carica, ma i messaggi non arrivano maiLa 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 12000msHai 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.