Перейти к содержимому

Ваше собственное приложение

Рабочий пример лежит в example/mic_phone_demo. Скопируйте папку, поменяйте названия — и у вас есть приложение.

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

Блок files — это то единственное, о чём люди забывают. FiveM отдаёт только то, что ресурс перечислил там, поэтому страница, которой в нём нет, отвечает 404, и ваше приложение открывается в пустоту.

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

Это весь контракт, целиком: ничего не настраивается на телефоне, и ничего не перезапускается. Запустите свой ресурс — и приложение появится на главном экране: default = true кладёт его туда, default = false кладёт его вместо этого в App Store. В любом случае решение за вами, в вашем собственном файле.

Config.AppList.Custom существует для того, чтобы владелец сервера мог вас переопределить — назначить цену вашему приложению, раздать его предустановленным, выключить. Для работы вашего приложения он никогда не нужен.

Четыре вещи, которые вам не нужно делать, — а другие телефоны заставляют делать каждую из них:

  • Не нужно удалять приложение перед добавлением. Повторная регистрация того же id из того же ресурса обновляет его. Отклоняется только другой ресурс, претендующий на id, который уже кому-то принадлежит, и ему объясняют почему.
  • Не нужно ждать телефон. mic_phone:ready срабатывает, когда он готов принимать приложения, и снова при каждом его перезапуске, так что ваше приложение возвращается само после restart mic_phone.
  • Не нужно прибираться при остановке вашего ресурса. Ваши приложения уходят вместе с ним. Остановленный скрипт никогда не оставляет иконку, которая ничего не делает.
  • Не нужно перезапускать телефон. Приложение, зарегистрированное в тот момент, когда кто-то держит телефон в руках, появляется на нём тут же.
idбуквы, цифры, _ и -. Уникален на всём сервере
nameчто написано под иконкой
subtitle, description, features, versionстраница в App Store
developer, categoryпоказываются в App Store
colorфон иконки, когда нет icon
iconURL. https://cfx-nui-<resource>/path.png отдаёт её из вашей собственной папки
uiresource/path/index.html, страница вашего приложения. Опустите для приложения, работающего только на Lua. Путь внутри ресурса на этом сервере, никогда не внешний URL — см. ниже
landscapeстранице нужен раскрытый телефон
size, imagesчто показывает страница App Store. Картинки должны быть https://
defaulttrue кладёт его сразу на главный экран, а не в App Store
onOpen, onCloseвызываются, когда оно открывается и закрывается

Назначить собственную цену нельзя. Поля price нет, и это намеренно: число жило бы на клиенте, а цена, которую объявляет клиент, — это цена, которую клиент может изменить: App Store показал бы одну, а сервер списал бы ноль. Цены задаются в Config.AppList.Custom, который пишет владелец сервера и читает сервер. Если вы хотите, чтобы ваше приложение стоило денег, попросите его добавить строку; это одна строка.

Последнее слово за городом. Всё, что вы здесь запрашиваете, — это просьба. Config.AppList.Custom в собственном конфиге телефона может назначить цену вашему приложению, раздать его предустановленным или полностью его отключить, и никому не нужно править ваш ресурс. Это сделано намеренно: владельцу сервера не должно приходиться форкать скрипт, чтобы брать за него деньги.

Ваша страница — это iframe. Она не может залезть в телефон, прочитать сообщения или рисовать за пределами собственного прямоугольника; телефон тоже не может залезть в неё. Всё пересекает границу в виде сообщений.

ui должен быть путём внутри ресурса на этом сервере. Чтобы фрейм загрузился, Content-Security-Policy страницы должна разрешать его origin, а в CSP нет способа сказать «любой ресурс cfx-nui-» — подстановочный знак допускается только как целая крайняя левая метка, поэтому https://cfx-nui-* написать нельзя, и директиву приходится открывать для https:, чтобы ваш фрейм вообще загрузился. Это широко, поэтому телефон сужает её с другой стороны: он сам строит origin из пути вашего ресурса и отклоняет всё, что уже является URL. Если вы хотите отдавать свою страницу откуда-то ещё, отдавайте её из собственной папки.

На вашей странице две строки:

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

и слушайте:

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

В вашем 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)

Отправляйте что угодно из onOpen. Сообщение для страницы, которая ещё не загрузилась, ждёт её и приходит по порядку в тот момент, когда она говорит phone:ready. Другие телефоны предупреждают, что так делать нельзя, потому что их сообщение опережает фрейм; здесь этого нет, потому что отправить что-то при открытии приложения — самое очевидное действие, а очевидные вещи должны работать.

phone:theme приходит со значениями light/dark телефона и его языком, когда ваша страница здоровается. Следуйте ей — и ваше приложение перестанет выглядеть как веб-сайт, который кто-то прикрутил сбоку.

Ваша страница может попросить телефон сделать то, что он и так умеет: показать уведомление, открыть камеру, дать выбрать фото или контакт. Пятнадцать строк всё настраивают, а затем каждый вопрос — это один 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
ВопросЧто возвращается
me{ number, name }то, что другие телефоны уже видят, и больше ничего о человеке
notifytrueзаписывается под вашим приложением, так что отключение в Настройках отключает вас
toasttrueстрока внизу экрана
photos.pickодно фото или nullоткрывает библиотеку телефона; null означает, что её закрыли
camera.takeодно фото или nullоткрывает настоящую Камеру, ждёт и возвращает вас обратно
contacts.pickодин контакт или nullоткрывает список имён
theme{ theme, language, tablet }те же значения, что отправляет phone:theme
closetrueвозвращает на главный экран

А вот половина, которая отдаёт что-то обратно телефону:

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 }
ВопросЧто возвращается
message.composetrueоткрывает Сообщения с текстом в поле. Отправку нажимает человек
call.starttrueзвонит на номер
share.text / share.photo{ to, number } или nullоткрывает выбор, затем составляет сообщение выбранному адресату
photos.save{ id, width, height }сохраняет картинку, созданную вашим приложением, в Фото — как снимок с камеры
waypoint.settrueметка на карте
money.balance{ balance }только чтение — деньги перемещаются на сервере, см. ниже

Ничего не отправляется от чьего-либо имени так, чтобы человек этого не видел. message.compose заполняет поле и оставляет кнопку отправки человеку; при передаче каждый раз спрашивается, кому. Приложение, которое могло бы молча писать вашим контактам, хуже, чем приложение, которое вообще не может писать.

Деньги перемещаются на сервере, из вашего собственного серверного файла, потому что цена, с которой может спорить клиент, — это не цена. Три двери, и какая нужна вам, зависит от того, кто переместил деньги:

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

Все три сразу обновляют Wallet на экране. Charge и Pay либо проходят целиком, либо не проходят вовсе: отклонённое списание оставляет баланс и историю точно такими, какими они были.

На этом держатся две вещи, и именно поэтому список короткий, а не щедрый:

Ваше приложение никогда не получает данные, которые ему никто не передавал. contacts.pick не возвращает адресную книгу — он открывает список, человек нажимает на одно имя, и это одно имя возвращается. Вопросы в цикле не читают телефон; каждый ответ стоит нажатия. То же самое касается фотографий.

Спрашивать может только приложение на экране. Вопрос из фрейма, который не является открытым приложением, остаётся без ответа и ничего не открывает, поэтому страница, оставленная где-то работающей, не может действовать, пока человек занят другим.

Если того, что вам нужно, нет в этом списке, это не значит, что телефон отказывает, — это значит, что никто этого не добавил. Добавление — это запись в PHONE_APP_ASKS в html/apps/store/app-api.js и больше ничего.

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 — это то, к чему стоит обратиться, когда ваше приложение не появляется. Серверная команда phoneapps выводит то, что говорит конфиг, а это другой список: ваше приложение живёт на клиенте, который его зарегистрировал, и появляется в phoneapps, только если кто-то дал ему запись в Config.AppList.Custom.

Приложение, которое не default = true и не installed в конфиге, лежит в App Store, пока кто-нибудь его не скачает. Если ваше зарегистрировано, а на главном экране вы его найти не можете, обычно причина именно в этом.

Что вы видитеПочти всегда
Приложения нигде нетСначала RegisteredApps(). Если его там нет, AddApp был отклонён — причина выводится в клиентской консоли (F8), а не в серверной
Оно зарегистрировано, но не на главном экранеОно в App Store: вы установили default = false или владелец прописал How = 'store' в Config.AppList.Custom
Приложение открывается на пустой страницеВашего файла ui нет в блоке files вашего fxmanifest
Refused to frame в консолиВаш ui — это URL. Он должен быть путём внутри ресурса на этом сервере
Страница загружается, сообщения не приходятВаша страница так и не отправила phone:ready. Пока этого нет, ничего не доставляется — оно удерживается, а не теряется, поэтому всё придёт разом, когда вы его отправите
no answer in 12000msВы вызвали то, чего у телефона нет. Сверьте название со списком выше

phoneapps на сервере выводит конфиг, а не ваше приложение: приложение, зарегистрированное ресурсом, живёт на клиенте, который его зарегистрировал, и появляется там, только если кто-то дал ему запись в Config.AppList.Custom. exports.mic_phone:RegisteredApps() — это список, который на самом деле есть у этой машины.

Остальной API телефона — state bag’и, звонки, контакты, уведомления — описан в разделе Экспорты и state bag’и.