Pular para o conteúdo

O seu próprio app nele

Um exemplo funcional está em example/mic_phone_demo. Copie a pasta, mude os nomes e você terá um aplicativo.

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

O bloco files é a única coisa que as pessoas esquecem. O FiveM só serve o que um recurso lista ali, então uma página que não esteja nessa lista responde 404 e o seu aplicativo abre em branco.

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

Esse é o contrato inteiro, e é tudo: nada é configurado no telefone e nada é reiniciado. Inicie o seu recurso e o aplicativo estará na Tela de Início — default = true o coloca lá, default = false o coloca na App Store. De qualquer forma, a decisão é sua, no seu próprio arquivo.

Config.AppList.Custom existe para que o dono do servidor possa sobrepor a sua escolha — colocar preço no seu aplicativo, entregá-lo pré-instalado ou desativá-lo. Isso nunca é necessário para que o seu aplicativo funcione.

Quatro coisas que você não precisa fazer, mas que outros telefones obrigam:

  • Você não remove o aplicativo antes de adicioná-lo. Registrar o mesmo ID a partir do mesmo recurso apenas o atualiza. Somente um recurso diferente tentando reivindicar um ID que já pertence a outro é recusado, e informado do motivo.
  • Você não precisa esperar pelo telefone. mic_phone:ready dispara quando ele está pronto para receber aplicativos e novamente toda vez que ele reinicia, então o seu aplicativo volta sozinho após um restart mic_phone.
  • Você não precisa limpar nada quando o seu recurso para. Seus aplicativos vão embora com ele. Um script parado nunca deixa para trás um ícone morto que não faz nada.
  • Você não precisa reiniciar o telefone. Um aplicativo registrado enquanto alguém está com o telefone na mão aparece na tela na mesma hora.
idletras, números, _ e -. Único em todo o servidor
nameo nome exibido no ícone
subtitle, description, features, versiona página da App Store
developer, categoryexibidos na App Store
colora cor de fundo do ícone quando não há icon
iconuma URL. https://cfx-nui-<resource>/path.png serve o arquivo a partir da sua própria pasta
uiresource/path/index.html, a página do seu aplicativo. Omita para um aplicativo que só executa Lua. Um caminho dentro de um recurso neste servidor, nunca uma URL externa — veja abaixo
landscapea página quer o telefone desdobrado
size, imageso que a página da App Store mostra. As imagens devem usar https://
defaulttrue coloca direto na Tela de Início em vez de na App Store
onOpen, onClosechamados quando o aplicativo abre e quando fecha

Você não pode definir o próprio preço. Não existe um campo price, e isso é proposital: o número viveria no cliente, e um preço declarado pelo cliente é um preço que o cliente pode alterar — a App Store mostraria um valor e o servidor não cobraria nada. A precificação fica em Config.AppList.Custom, escrita pelo dono do servidor e lida pelo servidor. Se você quiser que o seu aplicativo custe dinheiro, peça para adicionarem uma linha lá; basta uma.

A cidade tem a última palavra. Tudo o que você pede aqui é uma solicitação. Config.AppList.Custom na configuração do próprio telefone pode colocar preço no seu aplicativo, entregá-lo pré-instalado ou desativá-lo por completo, sem que ninguém precise editar o seu recurso. Isso é deliberado — o dono de um servidor não deveria ter que modificar o código de um script para cobrar por ele no jogo.

A sua página é um iframe. Ela não consegue alcançar o interior do telefone, ler mensagens ou desenhar fora do próprio retângulo; o telefone também não consegue invadir o espaço dela. Tudo atravessa por meio de mensagens.

ui precisa ser um caminho dentro de um recurso neste servidor. Um frame precisa que a Content-Security-Policy (CSP) da página permita a sua origem, e a CSP não tem como dizer “qualquer recurso cfx-nui-” — um curinga só é permitido como um rótulo inteiro mais à esquerda, portanto https://cfx-nui-* não pode ser escrito e a diretiva precisa ser aberta para https: para que o seu frame possa carregar. Isso é amplo, então o telefone restringe do outro lado: ele constrói a origem a partir do próprio caminho do seu recurso e recusa qualquer coisa que já seja uma URL. Se quiser servir a sua página de outro lugar, sirva-a a partir da sua própria pasta.

Na sua página, duas linhas:

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

e escute:

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

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

Envie o que quiser a partir de onOpen. Uma mensagem para uma página que ainda não carregou fica aguardando e chega em ordem no exato momento em que a página envia phone:ready. Outros telefones avisam para você não fazer isso porque a mensagem deles aposta corrida com o carregamento do frame; aqui isso não acontece, porque enviar dados quando o aplicativo abre é a coisa óbvia a se fazer, e coisas óbvias devem funcionar.

phone:theme chega com o modo light/dark do telefone e o idioma assim que a sua página diz olá. Siga-o e o seu aplicativo deixará de parecer um site enxertado às pressas.

A sua página pode pedir ao telefone para fazer coisas que ele já sabe fazer — mostrar uma notificação, abrir a câmera, deixar alguém escolher uma foto ou um contato. Quinze linhas configuram tudo e, depois disso, cada pergunta é um simples 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
PerguntaO que retorna
me{ number, name }o que os outros telefones já veem, e mais nada sobre a pessoa
notifytrueregistrado sob o seu app, então silenciá-lo nos Ajustes silencia você
toasttrueum aviso rápido na parte inferior da tela
photos.pickuma foto, ou nullabre a biblioteca do telefone; null significa que foi fechado sem escolher
camera.takeuma foto, ou nullabre a Câmera real, aguarda e traz você de volta
contacts.pickum contato, ou nullabre uma lista de nomes
theme{ theme, language, tablet }os mesmos valores que phone:theme envia
closetruevolta para a Tela de Início

E a outra metade, que entrega algo de volta ao telefone:

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 }
PerguntaO que retorna
message.composetrueabre o app Mensagens com o texto preenchido. Um humano aperta enviar
call.starttrueliga para o número
share.text / share.photo{ to, number }, ou nullabre um seletor e prepara o envio para quem foi escolhido
photos.save{ id, width, height }salva uma imagem criada pelo seu app no app Fotos, como se fosse da câmera
waypoint.settruemarca um ponto no mapa
money.balance{ balance }apenas leitura — o dinheiro se move no servidor, veja abaixo

Nada é enviado em nome de alguém sem que a pessoa veja. message.compose preenche o campo de texto e deixa o botão de enviar para o jogador; o compartilhamento pergunta para quem é todas as vezes. Um aplicativo que pudesse enviar mensagens silenciosamente para os seus contatos seria muito pior do que um aplicativo que não pode enviar mensagem alguma.

O dinheiro se move no servidor, a partir do seu próprio arquivo de servidor, porque um preço com o qual o cliente pode discutir não é um preço. Três portas de entrada, e qual usar depende de quem moveu o dinheiro:

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

Todas as três atualizam a Carteira na tela imediatamente. Charge e Pay falham por completo ou têm sucesso por completo: uma cobrança recusada deixa o saldo e o histórico exatamente como estavam.

Dois princípios sustentam isso, e explicam por que a lista é enxuta em vez de permissiva:

O seu aplicativo nunca recebe dados que ninguém lhe entregou. contacts.pick não retorna a agenda inteira — ele abre uma lista, a pessoa toca em um nome e apenas esse nome retorna. Perguntar em loop não lê o telefone; cada resposta exige um toque do usuário. O mesmo vale para as fotos.

Apenas o aplicativo visível na tela pode perguntar. Uma pergunta vinda de um frame que não seja o aplicativo aberto no momento não é respondida e não abre nada, de modo que uma página deixada rodando em segundo plano não pode agir enquanto alguém está fazendo outra coisa.

Se o que você precisa não está nessa lista, não é porque o telefone se recusa — é apenas porque ninguém adicionou ainda. Adicionar um item é colocar uma entrada em PHONE_APP_ASKS dentro de html/apps/store/app-api.js e nada mais.

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 é o export que você deve usar quando o seu aplicativo não estiver aparecendo. O comando phoneapps do servidor imprime o que a configuração diz, que é uma lista diferente — o seu aplicativo vive no cliente que o registrou, e só aparece em phoneapps se alguém tiver criado uma entrada para ele em Config.AppList.Custom.

Um aplicativo que não seja default = true nem installed na configuração fica na App Store até que alguém o baixe. Se o seu está registrado e você não o encontra na Tela de Início, geralmente o motivo é esse.

O que você vêQuase sempre é
O aplicativo não aparece em lugar nenhumRode RegisteredApps() primeiro. Se não estiver lá, AddApp foi recusado — o motivo é impresso no console do cliente (F8), não no do servidor
Está registrado, mas não na Tela de InícioEstá na App Store: você definiu default = false, ou o dono do servidor colocou How = 'store' em Config.AppList.Custom
O aplicativo abre uma página em brancoO seu arquivo ui não está no bloco files do seu fxmanifest.lua
Refused to frame no consoleO seu ui é uma URL. Ele precisa ser um caminho dentro de um recurso neste servidor
A página carrega, mas as mensagens nunca chegamA sua página nunca enviou phone:ready. Nada é entregue até que ela envie — as mensagens ficam retidas, não perdidas, então chegam todas de uma vez
no answer in 12000msVocê chamou algo que o telefone não tem. Confira o nome na lista acima

phoneapps no servidor imprime a configuração, não o seu aplicativo — um aplicativo registrado por um recurso vive no cliente que o registrou e só aparece lá se alguém tiver dado a ele uma entrada em Config.AppList.Custom. exports.mic_phone:RegisteredApps() é a lista que aquela máquina realmente possui.

O restante da API do telefone — state bags, chamadas, contatos, notificações — está em Exports e state bags.