Przejdź do głównej zawartości

Własna aplikacja w telefonie

Działający przykład znajduje się w example/mic_phone_demo. Skopiuj folder, zmień nazwy i masz aplikację.

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

Blok files to jedyna rzecz, o której wszyscy zapominają. FiveM serwuje tylko to, co zasób tam wymieni, więc strona, której tam nie ma, odpowiada 404, a Twoja aplikacja otwiera się do niczego.

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

To cały kontrakt i naprawdę całość: nic nie jest konfigurowane w telefonie i nic nie jest restartowane. Uruchom swój zasób, a aplikacja jest na Ekranie głównym — default = true umieszcza ją tam, default = false umieszcza ją zamiast tego w App Store. Tak czy inaczej decyzja należy do Ciebie, w Twoim własnym pliku.

Config.AppList.Custom istnieje po to, by właściciel serwera mógł Cię nadpisać — wycenić Twoją aplikację, rozdać ją preinstalowaną, wyłączyć. Nigdy nie jest potrzebne, by Twoja aplikacja działała.

Cztery rzeczy, których nie musisz robić, a które inne telefony od Ciebie wymagają:

  • Nie usuwasz aplikacji przed jej dodaniem. Zarejestrowanie tego samego id z tego samego zasobu aktualizuje ją. Odmowa i wyjaśnienie dotyczą tylko sytuacji, gdy inny zasób zgłasza id, które ktoś już posiada.
  • Nie czekasz na telefon. mic_phone:ready odpala, gdy telefon może przyjmować aplikacje, i ponownie przy każdym jego restarcie, więc Twoja aplikacja wraca sama po restart mic_phone.
  • Nie sprzątasz, gdy Twój zasób się zatrzymuje. Twoje aplikacje znikają razem z nim. Zatrzymany skrypt nigdy nie zostawia po sobie ikony, która nic nie robi.
  • Nie restartujesz telefonu. Aplikacja zarejestrowana, gdy ktoś trzyma swój telefon, pojawia się na nim od razu.
idlitery, cyfry, _ i -. Unikalne na całym serwerze
nameto, co mówi ikona
subtitle, description, features, versionstrona w App Store
developer, categorypokazywane w App Store
colortło ikony, gdy nie ma icon
iconadres URL. https://cfx-nui-<resource>/path.png serwuje ją z Twojego własnego folderu
uiresource/path/index.html, strona Twojej aplikacji. Pomiń dla aplikacji, która działa tylko w Lua. Ścieżka wewnątrz zasobu na tym serwerze, nigdy zewnętrzny URL — zobacz niżej
landscapestrona chce, by telefon był rozłożony
size, imagesto, co pokazuje strona w App Store. Obrazki muszą być https://
defaulttrue umieszcza ją od razu na Ekranie głównym zamiast w App Store
onOpen, onClosewywoływane, gdy się pojawia i gdy znika

Nie możesz ustawić własnej ceny. Nie ma pola price i to celowo: liczba żyłaby na kliencie, a cena, którą deklaruje klient, to cena, którą klient może zmienić — App Store pokazałby jedną, a serwer nie pobrałby niczego. Wycena to Config.AppList.Custom, pisane przez właściciela serwera i czytane przez serwer. Jeśli chcesz, by Twoja aplikacja kosztowała, poproś go o dodanie linijki; to jedna linijka.

Miasto ma ostatnie słowo. Wszystko, o co tu prosisz, jest prośbą. Config.AppList.Custom we własnym configu telefonu może wycenić Twoją aplikację, rozdać ją preinstalowaną albo całkowicie wyłączyć, bez edytowania Twojego zasobu przez kogokolwiek. To celowe — właściciel serwera nie powinien musieć robić forka skryptu, by za niego pobierać opłatę.

Twoja strona to iframe. Nie może sięgnąć do telefonu, czytać wiadomości ani rysować poza własnym prostokątem; telefon nie może też sięgnąć do niej. Wszystko przechodzi w wiadomościach.

ui musi być ścieżką wewnątrz zasobu na tym serwerze. Ramka wymaga, by Content-Security-Policy strony zezwalała na jej origin, a CSP nie ma sposobu, by powiedzieć “dowolny zasób cfx-nui-” — symbol wieloznaczny jest dozwolony tylko jako cała skrajnie lewa etykieta, więc https://cfx-nui-* nie da się zapisać, a dyrektywę trzeba otworzyć na https:, żeby Twoja ramka w ogóle się załadowała. To szeroko, więc telefon zawęża to po drugiej stronie: sam buduje origin ze ścieżki Twojego zasobu i odrzuca wszystko, co już jest adresem URL. Jeśli chcesz serwować swoją stronę skądinąd, serwuj ją z własnego folderu.

W Twojej stronie dwie linijki:

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

i nasłuchuj:

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

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

Wysyłaj z onOpen, co chcesz. Wiadomość dla strony, która jeszcze się nie załadowała, czeka na nią i dociera po kolei w chwili, gdy ta powie phone:ready. Inne telefony ostrzegają przed robieniem tego, bo ich wiadomość ściga się z ramką; tutaj nie, ponieważ wysłanie czegoś przy otwarciu aplikacji to rzecz oczywista, a oczywiste rzeczy powinny działać.

phone:theme przychodzi z light/dark telefonu i jego językiem, gdy Twoja strona się przywita. Idź za tym, a Twoja aplikacja przestanie wyglądać jak strona internetowa, którą ktoś przykręcił.

Twoja strona może poprosić telefon o rzeczy, które ten już potrafi — pokazanie powiadomienia, otwarcie aparatu, pozwolenie komuś na wybranie zdjęcia lub kontaktu. Piętnaście linijek to konfiguruje, a potem każde pytanie to jedno 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
PytanieCo wraca
me{ number, name }to, co inne telefony już widzą, i nic więcej o tej osobie
notifytruezapisywane pod Twoją aplikacją, więc wyciszenie jej w Ustawieniach wycisza Ciebie
toasttruelinijka na dole ekranu
photos.pickjedno zdjęcie lub nullotwiera bibliotekę telefonu; null oznacza, że zamknięto okno
camera.takejedno zdjęcie lub nullotwiera prawdziwy Aparat, czeka i przywraca Cię z powrotem
contacts.pickjeden kontakt lub nullotwiera arkusz z nazwiskami
theme{ theme, language, tablet }te same wartości, które wypycha phone:theme
closetruewraca do Ekranu głównego

A teraz połowa, która coś oddaje telefonowi:

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 }
PytanieCo wraca
message.composetrueotwiera Messages ze słowami w polu. Człowiek naciska wyślij
call.starttruedzwoni pod numer
share.text / share.photo{ to, number } lub nullotwiera wybierak, a potem tworzy wiadomość do wybranej osoby
photos.save{ id, width, height }zachowuje obraz stworzony przez Twoją aplikację w Photos, jak zdjęcie z aparatu
waypoint.settruepinezka na mapie
money.balance{ balance }tylko odczyt — pieniądze przesuwają się na serwerze, poniżej

Nic nie jest wysyłane w czyimś imieniu bez jego wiedzy. message.compose wypełnia pole i zostawia przycisk wysyłania człowiekowi; udostępnianie za każdym razem pyta, dla kogo ma być. Aplikacja, która mogłaby po cichu pisać SMS-y do Twoich kontaktów, byłaby gorszą rzeczą niż aplikacja, która w ogóle nie potrafi pisać.

Pieniądze przesuwają się na serwerze, z Twojego własnego pliku serwerowego, bo cena, z którą klient może się spierać, nie jest ceną. Trzy drzwi, a to, których potrzebujesz, zależy od tego, kto je przesunął:

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

Wszystkie trzy aktualizują Wallet na ekranie od razu. Charge i Pay kończą się w całości albo wcale: odrzucone obciążenie zostawia saldo i historię dokładnie takie, jakie były.

Dwie rzeczy to podtrzymują i dlatego lista jest krótka, a nie hojna:

Twoja aplikacja nigdy nie dostaje danych, których nikt jej nie wręczył. contacts.pick nie zwraca książki adresowej — otwiera arkusz, człowiek dotyka jednego nazwiska i to jedno nazwisko wraca. Pytanie w pętli nie czyta telefonu; każda odpowiedź kosztuje dotknięcie. To samo dotyczy zdjęć.

Zapytać może tylko aplikacja na ekranie. Pytanie z ramki, która nie jest otwartą aplikacją, nie dostaje odpowiedzi i niczego nie otwiera, więc strona pozostawiona gdzieś uruchomiona nie może działać, gdy ktoś robi coś innego.

Jeśli tego, czego potrzebujesz, nie ma na tej liście, to nie dlatego, że telefon odmawia — tylko dlatego, że nikt tego jeszcze nie dodał. Dodanie jednego to wpis w PHONE_APP_ASKS w html/apps/store/app-api.js i nic więcej.

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 to to, po co sięgasz, gdy Twoja aplikacja się nie pojawia. Serwerowe phoneapps wypisuje to, co mówi config, czyli inną listę — Twoja aplikacja żyje na kliencie, który ją zarejestrował, i pojawia się w phoneapps tylko wtedy, gdy ktoś dał jej wpis w Config.AppList.Custom.

Aplikacja, która nie ma default = true ani nie jest installed w configu, siedzi w App Store, dopóki ktoś jej nie pobierze. Jeśli Twoja jest zarejestrowana, a nie możesz jej znaleźć na Ekranie głównym, to zwykle dlatego.

Co widziszPrawie zawsze
Aplikacji nigdzie nie maNajpierw RegisteredApps(). Jeśli jej tam nie ma, AddApp został odrzucony — powód jest wypisywany w konsoli klienta (F8), a nie serwera
Jest zarejestrowana, ale nie ma jej na Ekranie głównymJest w App Store: ustawiłeś default = false albo właściciel wpisał How = 'store' w Config.AppList.Custom
Aplikacja otwiera się do pustej stronyTwojego pliku ui nie ma w bloku files w Twoim fxmanifest
Refused to frame w konsoliTwoje ui to URL. Musi to być ścieżka wewnątrz zasobu na tym serwerze
Strona się ładuje, wiadomości nigdy nie docierająTwoja strona nigdy nie wysłała phone:ready. Nic nie jest dostarczane, dopóki tego nie zrobi — jest wstrzymywane, a nie tracone, więc wszystko przychodzi naraz, gdy ją wyślesz
no answer in 12000msWywołałeś coś, czego telefon nie ma. Sprawdź nazwę z listą powyżej

phoneapps na serwerze wypisuje config, a nie Twoją aplikację — aplikacja zarejestrowana przez zasób żyje na kliencie, który ją zarejestrował, i pojawia się tam tylko wtedy, gdy ktoś dał jej wpis w Config.AppList.Custom. exports.mic_phone:RegisteredApps() to lista, którą ta maszyna faktycznie ma.

Reszta API telefonu — state bagi, połączenia, kontakty, powiadomienia — znajduje się w Eksporty i state bagi.