Aller au contenu

Votre propre application dessus

Un exemple fonctionnel se trouve dans example/mic_phone_demo. Copiez le dossier, modifiez les noms, et vous avez une application.

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',
}

Le bloc files est l’élĂ©ment que tout le monde oublie. FiveM ne sert que ce qu’une ressource dĂ©clare dans cette liste, donc une page qui n’y figure pas rĂ©pond par une 404 et votre application s’ouvre sur un cadre blanc.

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

C’est l’intĂ©gralitĂ© du contrat, et il n’y a rien de plus : rien n’est configurĂ© sur le tĂ©lĂ©phone, et rien n’est redĂ©marrĂ©. DĂ©marrez votre ressource et l’application apparaĂźt sur l’écran d’accueil — default = true la place directement lĂ , default = false la place dans l’App Store. Dans les deux cas, la dĂ©cision vous appartient, dans votre propre fichier.

Config.AppList.Custom existe pour qu’un propriĂ©taire de serveur puisse prĂ©valoir sur vos choix — tarifer votre application, l’offrir prĂ©installĂ©e ou la dĂ©sactiver. Il n’est jamais requis pour que votre application fonctionne.

Quatre contraintes que vous n’avez pas Ă  subir, contrairement Ă  d’autres tĂ©lĂ©phones :

  • Vous n’avez pas Ă  supprimer l’application avant de la rajouter. Enregistrer le mĂȘme ID depuis la mĂȘme ressource la met Ă  jour. Seule une ressource diffĂ©rente revendiquant un ID dĂ©jĂ  attribuĂ© est refusĂ©e, avec indication du motif.
  • Vous n’avez pas Ă  attendre le tĂ©lĂ©phone. mic_phone:ready se dĂ©clenche lorsqu’il est prĂȘt Ă  recevoir des applications et Ă  nouveau aprĂšs chaque redĂ©marrage, votre application revient donc d’elle-mĂȘme aprĂšs un restart mic_phone.
  • Vous n’avez pas Ă  nettoyer lors de l’arrĂȘt de votre ressource. Vos applications partent avec elle. Un script arrĂȘtĂ© ne laisse jamais derriĂšre lui une icĂŽne inactive.
  • Vous n’avez pas Ă  redĂ©marrer le tĂ©lĂ©phone. Une application enregistrĂ©e pendant qu’un joueur tient son tĂ©lĂ©phone en main apparaĂźt dessus instantanĂ©ment.
idlettres, chiffres, _ et -. Unique sur l’ensemble du serveur
namenom affichĂ© sous l’icĂŽne
subtitle, description, features, versionla page sur l’App Store
developer, categoryaffichĂ©s dans l’App Store
colorcouleur de fond de l’icîne lorsqu’aucune image icon n’est fournie
iconune URL. https://cfx-nui-<resource>/path.png la sert depuis votre propre dossier
uiresource/path/index.html, la page de votre application. À omettre pour une app 100 % Lua. Un chemin interne à une ressource de ce serveur, jamais une URL externe — voir ci-dessous
landscapeindique que la page nécessite le téléphone déplié en tablette
size, imagesĂ©lĂ©ments affichĂ©s sur la page App Store. Les images doivent ĂȘtre en https://
defaulttrue la dĂ©pose directement sur l’écran d’accueil au lieu de l’App Store
onOpen, onClosefonctions appelĂ©es Ă  l’ouverture et Ă  la fermeture

Vous ne pouvez pas fixer votre propre prix. Il n’y a pas de champ price, et c’est volontaire : cette valeur rĂ©siderait cĂŽtĂ© client, et un prix dĂ©clarĂ© par le client est un prix manipulable par le client — l’App Store afficherait un montant et le serveur ne prĂ©lĂšverait rien. La tarification est du ressort de Config.AppList.Custom, Ă©crit par le propriĂ©taire du serveur et lu par le serveur. Si vous souhaitez que votre application soit payante, demandez-lui d’ajouter une ligne ; cela ne prend qu’une ligne.

La ville a le dernier mot. Tout ce que vous demandez ici constitue une suggestion. Config.AppList.Custom dans la configuration du tĂ©lĂ©phone peut tarifer votre application, la distribuer prĂ©installĂ©e ou la dĂ©sactiver totalement, sans que personne n’ait Ă  modifier votre ressource. C’est dĂ©libĂ©rĂ© — un gestionnaire de serveur ne devrait pas avoir Ă  forker un script pour le faire payer.

Votre page est une iframe. Elle ne peut pas pĂ©nĂ©trer dans le tĂ©lĂ©phone, lire les messages ni dessiner hors de son rectangle ; le tĂ©lĂ©phone ne peut pas non plus pĂ©nĂ©trer dans votre iframe. Tout s’échange par messages.

ui doit ĂȘtre un chemin vers une ressource situĂ©e sur ce serveur. Une frame a besoin que la Content-Security-Policy de la page autorise son origine, et les CSP ne permettent pas d’écrire “n’importe quelle ressource cfx-nui-” — les jokers ne sont autorisĂ©s que sur le sous-domaine le plus Ă  gauche, donc https://cfx-nui-* ne peut pas ĂȘtre formulĂ© et la directive doit ĂȘtre ouverte Ă  https: pour que votre frame puisse charger. C’est vaste, alors le tĂ©lĂ©phone restreint cela de l’autre cĂŽtĂ© : il construit lui-mĂȘme l’origine d’aprĂšs le chemin de votre ressource et rejette tout ce qui se prĂ©sente dĂ©jĂ  comme une URL. Si vous souhaitez hĂ©berger votre page, faites-le depuis votre propre ressource.

Dans votre page, deux lignes :

parent.postMessage({ type: 'phone:ready' }, '*'); // say you have loaded
const send = m => parent.postMessage({ type: 'phone:message', message: m }, '*');

et écoutez :

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 */ }
});

Dans votre script 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)

Envoyez ce que vous voulez depuis onOpen. Un message destinĂ© Ă  une page qui n’a pas encore fini de charger attend sagement et arrive dans l’ordre dĂšs que celle-ci Ă©met phone:ready. D’autres tĂ©lĂ©phones dĂ©conseillent cette pratique car leurs messages entrent en concurrence avec le chargement de la frame ; ici ce n’est pas le cas, car envoyer des donnĂ©es Ă  l’ouverture de l’application est la dĂ©marche la plus naturelle qui soit, et ce qui est naturel doit fonctionner.

phone:theme est transmis avec le mode light/dark du téléphone et sa langue lorsque votre page signale sa disponibilité. Suivez ces valeurs et votre application cessera de ressembler à un site web plaqué artificiellement.

Votre page peut demander au tĂ©lĂ©phone d’exĂ©cuter des actions qu’il maĂźtrise dĂ©jĂ  — afficher une notification, ouvrir l’appareil photo, faire choisir une photo ou un contact. Quinze lignes initialisent le systĂšme, puis chaque requĂȘte se rĂ©sume Ă  un simple 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
DemandeCe qui est renvoyé
me{ number, name }ce que les autres tĂ©lĂ©phones voient dĂ©jĂ , et rien d’autre sur la personne
notifytrueclassé sous votre application, ainsi la mettre en sourdine dans les ParamÚtres vous met en sourdine
toasttrueun bandeau discret en bas de l’écran
photos.pickune photo, ou nullouvre la galerie du téléphone ; null signifie fermeture sans choix
camera.takeune photo, ou nullouvre l’Appareil photo en plein jeu, attend et vous ramùne sur l’app
contacts.pickun contact, ou nullouvre un volet de sélection de contacts
theme{ theme, language, tablet }les mĂȘmes valeurs que celles poussĂ©es par phone:theme
closetruerevient Ă  l’écran d’accueil

Et le volet qui permet d’interagir activement avec le tĂ©lĂ©phone :

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 }
DemandeCe qui est renvoyé
message.composetrueouvre Messages avec le texte prérempli. Un joueur appuie sur envoyer
call.starttruecompose et appelle le numéro
share.text / share.photo{ to, number }, ou nullouvre un sĂ©lecteur, puis prĂ©pare l’envoi vers le destinataire choisi
photos.save{ id, width, height }enregistre une image générée par votre app dans Photos
waypoint.settrueplace un repĂšre GPS sur la carte
money.balance{ balance }lecture seule — l’argent se dĂ©place cĂŽtĂ© serveur, voir ci-dessous

Rien n’est jamais envoyĂ© au nom d’un joueur Ă  son insu. message.compose remplit le champ de saisie et confie le bouton d’envoi Ă  l’humain ; le partage demande systĂ©matiquement le destinataire. Une application capable d’envoyer des SMS Ă  vos contacts en secret serait bien pire qu’une application dĂ©pourvue d’envoi de messages.

L’argent est manipulĂ© cĂŽtĂ© serveur, depuis votre propre script serveur, car un prix contestable par un client n’est pas un prix. Trois fonctions sont proposĂ©es, selon l’entitĂ© ayant rĂ©alisĂ© le mouvement :

-- 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)

Toutes trois mettent Ă  jour le Portefeuille Ă  l’écran instantanĂ©ment. Charge et Pay rĂ©ussissent intĂ©gralement ou Ă©chouent intĂ©gralement : un prĂ©lĂšvement refusĂ© laisse le solde et l’historique dans leur Ă©tat d’origine.

Deux principes fondamentaux justifient la concision de cette API :

Votre application ne reçoit jamais de donnĂ©es qu’on ne lui a pas explicitement donnĂ©es. contacts.pick ne renvoie pas le carnet d’adresses — il ouvre un volet, le joueur sĂ©lectionne un nom, et seul ce nom est renvoyĂ©. Interroger en boucle ne permet pas de siphonner le rĂ©pertoire ; chaque rĂ©ponse requiert une action utilisateur. Il en va de mĂȘme pour les photos.

Seule l’application Ă  l’écran a le droit de poser une question. Une demande provenant d’une frame qui n’est pas l’application actuellement ouverte n’obtient aucune rĂ©ponse et n’ouvre rien ; une page laissĂ©e en arriĂšre-plan ne peut donc pas agir pendant que le joueur fait autre chose.

Si ce dont vous avez besoin ne figure pas dans cette liste, ce n’est pas un refus du tĂ©lĂ©phone — c’est simplement que personne ne l’a encore ajoutĂ©. En ajouter une consiste en une entrĂ©e dans PHONE_APP_ASKS au sein de html/apps/store/app-api.js, rien de plus.

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 est l’export Ă  consulter lorsque votre application n’apparaĂźt pas. La commande serveur phoneapps affiche ce que prĂ©voit la configuration, ce qui reprĂ©sente une liste diffĂ©rente — votre application rĂ©side sur le client qui l’a enregistrĂ©e, et n’apparaĂźt dans phoneapps que si une entrĂ©e lui a Ă©tĂ© consacrĂ©e dans Config.AppList.Custom.

Une application qui n’est ni en default = true ni marquĂ©e comme installed dans la configuration attend dans l’App Store que quelqu’un la tĂ©lĂ©charge. Si la vĂŽtre est bien enregistrĂ©e mais introuvable sur l’écran d’accueil, c’en est gĂ©nĂ©ralement la cause.

Ce que vous constatezPresque toujours
L’application n’est nulle partConsultez d’abord RegisteredApps(). Si elle n’y figure pas, AddApp a Ă©tĂ© rejetĂ© — la raison est inscrite dans la console client (F8), et non serveur
Elle est enregistrĂ©e mais absente de l’accueilElle se trouve dans l’App Store : vous avez dĂ©fini default = false, ou le gestionnaire a mis How = 'store' dans Config.AppList.Custom
L’application s’ouvre sur une page blancheVotre fichier ui n’est pas rĂ©pertoriĂ© dans le bloc files de votre fxmanifest
Refused to frame dans la consoleVotre ui est une URL. Ce doit ĂȘtre un chemin vers une ressource situĂ©e sur ce serveur
La page charge, mais les messages n’arrivent pasVotre page n’a jamais envoyĂ© phone:ready. Rien n’est dĂ©livrĂ© tant qu’elle ne l’a pas fait — les messages patientent et arrivent tous Ă  l’envoi de ce signal
no answer in 12000msVous avez sollicité une action inexistante sur le téléphone. Vérifiez le nom avec la liste ci-dessus

phoneapps sur le serveur affiche la configuration et non votre application — une application enregistrĂ©e par une ressource vit sur le client qui l’a dĂ©clarĂ©e, et n’y apparaĂźt que si une entrĂ©e Config.AppList.Custom existe. exports.mic_phone:RegisteredApps() fournit la liste rĂ©ellement prĂ©sente sur cette machine.

Le reste de l’API du tĂ©lĂ©phone — state bags, appels, contacts, notifications — est disponible sur la page Exports et state bags.