
RARX Phone · Integrations
External apps API
Add your own app to rarx_phone from any resource with AddCustomApp, exchange messages with it and use the page SDK for theme, dialogs and pickers.
- Qbox
- QBCore
- ESX
- v2.0.0
rarx_phone
Overview#
Any resource on your server can add its own app to the phone. It shows up on the home screen, in the app drawer (Andromeda), in search, in multitasking and in the app store, with its own icon, and opens inside the phone as an app-sized iframe.
This is the equivalent of LB Phone's AddCustomApp / RemoveCustomApp / SendCustomAppMessage. An app built for LB is usually ported by replacing exports['lb-phone'] with exports.rarx_phone (see Differences with LB Phone). The client exports are also available as exports['lb-phone'].
Register the app#
From the client (recommended, same as LB)#
local ok, err = exports.rarx_phone:AddCustomApp({
identifier = 'my_app',
name = 'My app',
description = 'Text shown in the App Store.',
developer = 'My company',
category = 'utilities',
icon = 'https://cfx-nui-' .. GetCurrentResourceName() .. '/html/icon.png',
ui = GetCurrentResourceName() .. '/html/index.html',
defaultApp = false,
fullscreen = false,
background = '#101418',
onOpen = function() end,
onClose = function() end,
onInstall = function() end,
onUninstall = function() end,
onMessage = function(data) end,
})This registers the app for that player only. Since your client code runs on every player, in practice everyone gets it. Returns true or false, reason.
From the server#
local ok, err = exports.rarx_phone:AddCustomApp({
identifier = 'my_app',
name = 'My app',
ui = 'my_resource/html/index.html',
icon = 'nui://my_resource/html/icon.png',
onOpen = function(src) end,
onClose = function(src) end,
onInstall = function(src) end,
onUninstall = function(src) end,
onMessage = function(src, data) end,
})This registers the app for all players, including those who join later. Callbacks receive the player's source.
AddCustomApp(app)client / server export → true | false, reasonRegisters an external app. See the fields below. Possible reasons include invalid_identifier, invalid_ui, identifier_taken and already_exists.
Fields#
| Field | Required | Description |
|---|---|---|
identifier |
yes | Letters, numbers, _ or -, up to 48 characters. It cannot match a built-in phone app (config/apps.lua) or another external app (identifier_taken / already_exists). |
name |
yes | Name under the icon and in the store (max 40). |
description |
no | Store listing text (max 600). |
developer |
no | Shown in the listing. Defaults to the resource name. |
category |
no | social, productivity, utilities (default), photo, travel, entertainment, finance, shopping, lifestyle, games. |
icon |
no | https://... (any domain), nui://resource/path or resource/path. Square, ideally 180x180 or SVG; the phone applies the iFruitOS or Andromeda mask. Without an icon, the first letter is drawn. |
ui |
no | App page. Only resource/path.html (LB style), nui://resource/path.html or https://cfx-nui-resource/path.html are accepted, normalised to https://cfx-nui-resource/.... The resource must exist and the page must be listed in files {} of its fxmanifest.lua. Any other URL (http:, other domains, ..) is rejected with invalid_ui. |
defaultApp |
no | true: pre-installed and cannot be removed. false (default): listed in the store and the player installs or uninstalls it. |
fullscreen |
no | false (default): the iframe sits between the status bar and the home gesture, over the phone background (or background). true: it covers the whole screen and the page must respect --phone-safe-top / --phone-safe-bottom (set by the SDK). |
background |
no | #rrggbb colour of the frame when not fullscreen. If it is dark, the status bar is drawn light. |
keepOpen |
no | Only for apps without ui: tapping the icon fires onOpen and the phone returns home. With keepOpen = false (default) the phone also closes (like LB). |
landscape |
no | Accepted for LB compatibility and ignored: the phone has no landscape mode. |
onOpen, onClose, onInstall, onUninstall (alias onDelete), onMessage |
no | Callbacks. Client: fn(data); server: fn(src, data). data is only set for onMessage. They run in a separate thread inside pcall. |
Remove the app#
RemoveCustomApp(identifier)client / server export → true | false, reasonOnly the resource that registered the app can remove it (not_owner). When a resource stops, its apps are removed automatically. If the app is open when it is removed, the phone returns to the home screen.
exports.rarx_phone:RemoveCustomApp('my_app')List apps#
GetCustomApps()client / server export → tableReturns the apps registered on that side.
Messages from your resource to the page#
SendCustomAppMessage(...)client / server export → true | false, reason- Client:
SendCustomAppMessage(identifier, data). - Server:
SendCustomAppMessage(target, identifier, data);targetis asourceor-1for everyone.
Only the resource that owns the app can send (not_owner). Maximum 64 KB as JSON (too_big).
exports.rarx_phone:SendCustomAppMessage('my_app', { type = 'refresh' })
exports.rarx_phone:SendCustomAppMessage(src, 'my_app', { type = 'refresh' })
exports.rarx_phone:SendCustomAppMessage(-1, 'my_app', { type = 'refresh' })The page receives the data unchanged as a window message event (same as LB), and also through RarxPhone.onMessage:
window.addEventListener('message', (e) => { if (e.data && e.data.type === 'refresh') reload() })
RarxPhone.onMessage((data) => { /* ... */ })If the app is not open, the message is discarded (when the app opens, the page loads again and should request its state). While the page is still loading, messages are queued (up to 50) and delivered when it finishes.
From the page to your resource#
There are two ways, and you can use both.
-
Your own NUI callback (same as LB). The page lives at
https://cfx-nui-my_resource/..., so it can call theRegisterNUICallbackhandlers of its own resource:RegisterNUICallback('save', function(data, cb) cb({ ok = true }) end)const res = await RarxPhone.nui('save', { text })RarxPhone.nui(name, data)doesfetch('https://my_resource/name')and returns the JSON. It goes straight to your resource; the phone is not involved. -
Through the phone:
RarxPhone.send(data)callsonMessage(data)of the registration (client) oronMessage(src, data)(server, if registered on the server). The local eventrarx_phone:customAppis also fired. Maximum 16 KB; on the server, at most 40 events every 5 seconds per player.
Local events fired by the phone, if you prefer events over callbacks:
rarx_phone:customAppclient eventAddEventHandler('rarx_phone:customApp', function(identifier, action, data) end)rarx_phone:customAppserver eventAddEventHandler('rarx_phone:customApp', function(src, identifier, action, data) end)action is open, close, install, uninstall or message. These are local events (not network events), so a client cannot fake them towards the server.
Page SDK (sdk.js)#
<script src="https://cfx-nui-rarx_phone/ui/dist/sdk.js"></script>The SDK defines window.RarxPhone. All functions return a promise. Without the SDK the page still works (it opens, receives messages and can use its NUI callbacks), but it gets no theme, dialogs or proper text input handling.
| Function | Returns | What it does |
|---|---|---|
ready (promise) |
settings | Resolves when the phone answers. There is no theme before that. |
settings |
settings or null |
Last settings received. |
getSettings() |
settings | Requests the settings again. |
onSettings(fn) |
unsubscribe function | Theme, OS, language, accent, foldable changes... |
onOpen(fn) |
unsubscribe function | The already open app is opened again (for example from a notification) with new params. |
onMessage(fn) |
unsubscribe function | Messages from SendCustomAppMessage. |
notify({ title, content, avatar?, thumbnail? }) |
true |
Phone notification with the app icon (banner + notification centre; not stored in the database). Max 5 every 10 s. For persistent notifications use the server export SendNotification(src, { app = 'my_app', title, content }). |
toast(text, 'info' | 'success' | 'error') |
true |
Short toast. |
alert(title, message?, ok?) |
true |
System dialog. |
confirm(title, message?, { ok?, cancel?, destructive? }) |
boolean |
Confirmation dialog. |
prompt(title, { message?, placeholder?, value?, maxlength?, ok?, cancel? }) |
text or null |
Blocks game keys while typing. |
actionSheet({ title?, message?, actions: [{ label, value, role? }] }) |
value or null |
Action sheet (max 8; role: destructive or cancel). |
pickImage(max = 1) |
string[] |
Picker from the player's Gallery (up to 10). Returns the URLs. |
pickContact() |
{ name, number, avatar } or null |
Contact picker with search. null if closed. |
pickGif() |
{ url, preview, width, height } or null |
The phone's GIF picker (same as Messages). err_feature_off if GIFs are disabled on the server. |
pickEmoji() |
text or null |
Emoji picker by category. Returns one emoji. |
pickColor(value?) |
'#rrggbb' or null |
24-colour palette and hex field. value is the initial colour (#abc or #aabbcc). |
takePhoto({ gallery = true }?) |
{ url, width, height } or null |
Opens the camera viewfinder inside the app, takes the photo, uploads it and (unless gallery: false) saves it to the Gallery. err_feature_off if the camera is disabled. |
openApp(id, params?) |
true |
Opens another installed app (messages, phone, map...). params only accepts simple values. |
call(number, video = false) |
boolean |
Starts a call (the player sees it and can hang up). |
close() |
true |
Returns to the home screen. |
setBadge(n) |
true |
Number badge on the icon. |
setTyping(bool) |
true |
Blocks or releases game keys. The SDK already does this when an input, textarea or contenteditable gets focus. |
vibrate() |
true |
Controller vibration. |
statusBar('light' | 'dark' | null) |
true |
Status bar colour over the app (useful with fullscreen). |
send(data) |
true |
See From the page to your resource. |
nui(name, data) |
JSON | See From the page to your resource. |
resource |
text | Name of the page's resource. |
The pickers (pickImage, pickContact, pickGif, pickEmoji, pickColor, takePhoto) are sheets drawn by the phone on top of the app. Only one can be open at a time (otherwise err_busy), they do not time out (they wait until the player picks or closes) and they resolve with null if the app closes. When using postMessage directly without the SDK, the message type is the same name:
{ __rarxPhone: 1, id, type: 'pickColor', payload: { value: '#ff0000' } }Errors: the promise rejects with Error(code): err_not_allowed (action not in the list), err_busy (an app dialog is already open), err_rate_limited, err_app_unknown, err_too_big, err_timeout, err_no_phone (page opened outside the phone).
Settings object#
Returned by ready, getSettings and onSettings:
{
"appId": "my_app", "resource": "my_resource",
"os": "ios", "theme": "dark", "language": "en",
"accent": "#c9564f", "accentRgb": "201, 86, 79",
"model": "phone", "foldable": false, "wide": false,
"fullscreen": false, "safeTop": 0, "safeBottom": 0,
"currency": "$", "twelveHour": false, "streamerMode": false,
"phoneNumber": "551234", "params": {}
}The SDK also sets on <html>: data-os="ios|android", data-theme="light|dark", lang, and the CSS variables --phone-safe-top, --phone-safe-bottom (0 when not fullscreen), --phone-accent and --phone-accent-rgb. This lets the page look like iFruitOS or Andromeda and follow dark mode:
:root { --bg: #f2f2f7; --text: #111 }
:root[data-theme='dark'] { --bg: #000; --text: #f5f5f7 }
body { padding-top: var(--phone-safe-top, 0px); padding-bottom: var(--phone-safe-bottom, 0px) }Behaviour inside the phone#
- Home, drawer, search and multitasking: installed external apps appear after the built-in ones. Their order can be changed in edit mode like any other app.
- app store: every external app is listed with its description, category and developer. Installing or uninstalling an external app is saved in the player's settings (
settings.external.installed), not inrarx_phone_apps.defaultAppapps are always installed. - Gestures: the island / punch-hole, the status bar and the home gesture bar sit above the
iframe; swiping the bar closes the app like any other. The iFruitOS edge "back" gesture does not exist inside theiframe: the page handles its own navigation. - On close the
iframeis unloaded; when the app opens again the page reloads (onOpenin Lua,readyin the SDK). - Game key blocking: with the SDK, focusing an editable field enables
setTypingand leaving it releases the keys. Without the SDK, keys are blocked while focus is inside theiframeuntil it returns to the phone.
Security#
- Only server resources can register apps (the exports are not reachable by players). Each app stores the resource that registered it, and only that resource can remove it or send it messages.
- The page can only come from
https://cfx-nui-<resource>(ornui://<resource>) of an existing resource. This is validated in Lua and again in the UI. - The
iframeusessandbox="allow-scripts allow-same-origin allow-forms": it cannot navigate the phone, open windows or show native dialogs. - The phone only handles messages whose
event.sourceis that app'siframe, and only the SDK actions listed above (with size and rate limits). Anything else returnserr_not_allowed. Theiframecannot call the phone's RPCs through the bridge. - The phone ignores
{ action, data }messages coming from any childiframe(at any depth), so an external app cannot impersonate messages from Lua.
Differences with LB Phone#
| LB Phone | rarx_phone |
|---|---|
AddCustomApp client only |
Client and server. |
SendCustomAppMessage(id, msg) (client) |
Same on the client; on the server SendCustomAppMessage(target, id, msg). |
onUse, onServerUse |
Use onOpen (client) or onOpen(src) (server). |
size, price, images, game, fixBlur, disableInAppNotifications |
Not used (ignored). |
landscape |
Ignored (no landscape mode). |
LB components.js (setPopUp, setContextMenu, useNuiEvent...) |
sdk.js: confirm / alert / prompt / actionSheet / notify / pickImage / openApp... |
Injected globalThis.settings |
RarxPhone.ready / RarxPhone.settings and the data-theme / data-os attributes. |
To port an LB app: replace exports['lb-phone'] with exports.rarx_phone, GetResourceState('lb-phone') with 'rarx_phone', and if it reads globalThis.settings.display.theme, use RarxPhone.ready.then((s) => s.theme) with the SDK.
Minimal example#
A complete resource called rarx_ejemplo_app. Start it after rarx_phone, open the app store on the phone and install the app. The /ejemploapp <text> command sends a message to the open app.
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
name 'rarx_ejemplo_app'
client_script 'client.lua'
files {
'html/index.html',
'html/app.js',
'html/style.css',
'html/icon.svg',
}
dependency 'rarx_phone'local APP = 'ejemplo'
local RESOURCE = GetCurrentResourceName()
local function register()
if GetResourceState('rarx_phone') ~= 'started' then return end
local ok, err = exports.rarx_phone:AddCustomApp({
identifier = APP,
name = 'Example',
description = 'Example app for the rarx_phone external apps API.',
developer = 'Rarx Network',
category = 'utilities',
icon = ('https://cfx-nui-%s/html/icon.svg'):format(RESOURCE),
ui = RESOURCE .. '/html/index.html',
onOpen = function()
print('[example] app opened')
end,
onClose = function()
print('[example] app closed')
end,
onMessage = function(data)
if type(data) == 'table' and data.tipo == 'ping' then
exports.rarx_phone:SendCustomAppMessage(APP, { tipo = 'pong', hora = ('%02d:%02d'):format(GetClockHours(), GetClockMinutes()) })
end
end,
})
if not ok and err ~= 'already_exists' then
print(('[example] could not register the app: %s'):format(tostring(err)))
end
end
AddEventHandler('onClientResourceStart', function(res)
if res == 'rarx_phone' or res == RESOURCE then
SetTimeout(500, register)
end
end)
RegisterNUICallback('saludo', function(data, cb)
local name = type(data) == 'table' and type(data.nombre) == 'string' and data.nombre:sub(1, 30) or 'unknown'
cb({ texto = ('Hello, %s. This is the Lua client answering.'):format(name), id = GetPlayerServerId(PlayerId()) })
end)
RegisterCommand('ejemploapp', function(_, args)
exports.rarx_phone:SendCustomAppMessage(APP, { tipo = 'aviso', texto = table.concat(args, ' ') })
end, false)<script src="https://cfx-nui-rarx_phone/ui/dist/sdk.js"></script>
<input id="nombre" type="text">
<button id="saludar">Say hello</button>
<script>
RarxPhone.ready.then((s) => console.log(s.os, s.theme))
RarxPhone.onMessage((d) => console.log('from the resource', d))
document.querySelector('#saludar').onclick = async () => {
const r = await RarxPhone.nui('saludo', { nombre: document.querySelector('#nombre').value })
RarxPhone.toast(r.texto, 'success')
}
</script>