Ir al contenido

Tu propia app en él

Tienes un ejemplo que funciona en example/mic_phone_demo. Copia la carpeta, cambia los nombres y ya tienes una 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',
}

El bloque files es lo único que la gente olvida. FiveM solo sirve lo que un recurso lista ahí, así que una página que no esté en él responde 404 y tu app se abre en blanco.

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

Ese es todo el contrato, y no hay nada más: no se configura nada en el teléfono y no se reinicia nada. Arranca tu recurso y la app está en la pantalla de inicio: default = true la pone ahí, default = false la pone en la App Store. En cualquier caso la decisión es tuya, en tu propio archivo.

Config.AppList.Custom existe para que un dueño de servidor pueda imponerse a ti: ponerle precio a tu app, darla preinstalada, desactivarla. Nunca hace falta para que tu app funcione.

Cuatro cosas que no tienes que hacer, y que otros teléfonos sí te obligan a hacer:

  • No eliminas la app antes de añadirla. Registrar el mismo id desde el mismo recurso la actualiza. Solo se rechaza que un recurso distinto reclame un id que ya es de otro, y se le dice por qué.
  • No esperas al teléfono. mic_phone:ready se dispara cuando puede aceptar apps y otra vez cada vez que se reinicia, así que tu app vuelve sola después de restart mic_phone.
  • No limpias nada cuando tu recurso se detiene. Tus apps se van con él. Un script detenido nunca deja atrás un icono que no hace nada.
  • No reinicias el teléfono. Una app registrada mientras alguien tiene el suyo en la mano aparece en él en ese mismo momento.
idletras, números, _ y -. Único en todo el servidor
namelo que pone el icono
subtitle, description, features, versionla página de la App Store
developer, categoryse muestran en la App Store
colorel fondo del icono cuando no hay icon
iconuna URL. https://cfx-nui-<resource>/path.png lo sirve desde tu propia carpeta
uiresource/path/index.html, la página de tu app. Omítelo en una app que solo ejecuta Lua. Una ruta dentro de un recurso de este servidor, nunca una URL externa: mira más abajo
landscapela página quiere el teléfono desplegado
size, imageslo que muestra la página de la App Store. Las imágenes tienen que ser https://
defaulttrue la pone directamente en la pantalla de inicio en lugar de en la App Store
onOpen, onClosese llaman cuando aparece y cuando se va

No puedes fijar tu propio precio. No hay campo price, y es a propósito: el número viviría en el cliente, y un precio que declara el cliente es un precio que el cliente puede cambiar: la App Store mostraría uno y el servidor no cobraría nada. El precio está en Config.AppList.Custom, lo escribe el dueño del servidor y lo lee el servidor. Si quieres que tu app cueste dinero, pídele que añada una línea; es solo una.

La ciudad tiene la última palabra. Lo que pidas aquí es una petición. Config.AppList.Custom en la config del propio teléfono puede ponerle precio a tu app, darla preinstalada o desactivarla por completo, sin que nadie edite tu recurso. Es a propósito: un dueño de servidor no debería tener que hacer un fork de un script para cobrar por él.

Tu página es un iframe. No puede meterse en el teléfono, leer los mensajes ni dibujar fuera de su propio rectángulo; el teléfono tampoco puede meterse en ella. Todo pasa mediante mensajes.

ui tiene que ser una ruta dentro de un recurso de este servidor. Un frame necesita que la Content-Security-Policy de la página permita su origen, y CSP no tiene forma de decir “cualquier recurso cfx-nui-”: un comodín solo se permite como etiqueta completa más a la izquierda, así que https://cfx-nui-* no se puede escribir y la directiva tiene que abrirse a https: para que tu frame cargue siquiera. Eso es muy amplio, así que el teléfono lo acota por el otro lado: construye él mismo el origen a partir de la ruta de tu recurso y rechaza cualquier cosa que ya sea una URL. Si quieres servir tu página desde otro sitio, sírvela desde tu propia carpeta.

En tu página, dos líneas:

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

y escucha:

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

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

Envía lo que quieras desde onOpen. Un mensaje para una página que todavía no ha cargado la espera y llega en orden en cuanto dice phone:ready. Otros teléfonos te advierten de que no lo hagas porque su mensaje compite con el frame; aquí no, porque enviar algo cuando se abre tu app es lo obvio, y lo obvio debería funcionar.

phone:theme llega con el light/dark del teléfono y su idioma cuando tu página saluda. Síguelo y tu app dejará de parecer una web que alguien ha atornillado encima.

Tu página puede pedirle al teléfono que haga cosas que ya sabe hacer: mostrar una notificación, abrir la cámara, dejar que alguien elija una foto o un contacto. Quince líneas lo preparan, y después cada pregunta es un 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
PreguntaQué devuelve
me{ number, name }lo que otros teléfonos ya ven, y nada más sobre la persona
notifytruearchivada bajo tu app, así que silenciarla en Ajustes te silencia a ti
toasttrueuna línea en la parte inferior de la pantalla
photos.pickuna foto, o nullabre la fototeca del teléfono; null significa que se ha cerrado
camera.takeuna foto, o nullabre la Cámara de verdad, espera y te devuelve a tu app
contacts.pickun contacto, o nullabre una hoja con nombres
theme{ theme, language, tablet }los mismos valores que envía phone:theme
closetruevuelve a la pantalla de inicio

Y la mitad que le devuelve algo al teléfono:

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 }
PreguntaQué devuelve
message.composetrueabre Mensajes con el texto en la caja. Es una persona quien pulsa enviar
call.starttruellama al número
share.text / share.photo{ to, number }, o nullabre un selector y después redacta para quien se haya elegido
photos.save{ id, width, height }guarda una imagen que ha hecho tu app en Fotos, como una foto de la cámara
waypoint.settrueun pin en el mapa
money.balance{ balance }solo lectura: el dinero se mueve en el servidor, más abajo

No se envía nada en nombre de nadie sin que lo vea. message.compose rellena la caja y deja el botón de enviar a una persona; compartir pregunta cada vez para quién es. Una app que pudiera mandar mensajes a tus contactos en silencio sería peor que una app que no puede mandar mensajes en absoluto.

El dinero se mueve en el servidor, desde tu propio archivo de servidor, porque un precio que un cliente puede discutir no es un precio. Tres puertas, y cuál quieres depende de quién lo ha movido:

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

Las tres actualizan al instante el Wallet en pantalla. Charge y Pay fallan enteras o funcionan enteras: un cobro rechazado deja el saldo y el historial exactamente como estaban.

Dos cosas sostienen todo esto, y son la razón de que la lista sea corta en lugar de generosa:

Tu app nunca recibe datos que nadie le haya dado. contacts.pick no devuelve la agenda: abre una hoja, una persona toca un nombre y vuelve ese único nombre. Preguntar en bucle no lee el teléfono; cada respuesta cuesta un toque. Lo mismo pasa con las fotos.

Solo puede preguntar la app que está en pantalla. Una pregunta desde un frame que no es la app abierta no se responde y no abre nada, así que una página que se ha quedado ejecutándose en algún sitio no puede actuar mientras alguien está haciendo otra cosa.

Si lo que necesitas no está en esa lista, no es que el teléfono se niegue: es que nadie lo ha añadido. Añadir una es una entrada en PHONE_APP_ASKS en html/apps/store/app-api.js y nada más.

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 es el que tienes que usar cuando tu app no aparece. El phoneapps del servidor imprime lo que dice la config, que es otra lista: tu app vive en el cliente que la registró, y aparece en phoneapps solo si alguien le ha dado una entrada en Config.AppList.Custom.

Una app que no es ni default = true ni installed en la config se queda en la App Store hasta que alguien la descarga. Si la tuya está registrada y no la encuentras en la pantalla de inicio, normalmente es por eso.

Qué vesCasi siempre
La app no está en ningún sitioPrimero RegisteredApps(). Si no está ahí, AddApp se ha rechazado: el motivo se imprime en la consola del cliente (F8), no en la del servidor
Está registrada pero no en la pantalla de inicioEstá en la App Store: pusiste default = false, o el dueño puso How = 'store' en Config.AppList.Custom
La app se abre en una página en blancoTu archivo ui no está en el bloque files de tu fxmanifest
Refused to frame en la consolaTu ui es una URL. Tiene que ser una ruta dentro de un recurso de este servidor
La página carga, pero los mensajes nunca lleganTu página nunca envió phone:ready. No se entrega nada hasta que lo hace: se retiene, no se pierde, así que llega todo de golpe cuando lo envías
no answer in 12000msHas llamado a algo que el teléfono no tiene. Comprueba el nombre con la lista de arriba

phoneapps en el servidor imprime la config, no tu app: una app registrada por un recurso vive en el cliente que la registró, y aparece ahí solo si alguien le ha dado una entrada en Config.AppList.Custom. exports.mic_phone:RegisteredApps() es la lista que esa máquina tiene de verdad.

El resto de la API del teléfono (los state bags, las llamadas, los contactos, las notificaciones) está en Exports y state bags.