Docs
RARX Phone cover

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#

Lua
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#

Lua
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, reason

Registers 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, reason

Only 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.

Lua
exports.rarx_phone:RemoveCustomApp('my_app')

List apps#

GetCustomApps()client / server export → table

Returns 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); target is a source or -1 for everyone.

Only the resource that owns the app can send (not_owner). Maximum 64 KB as JSON (too_big).

Lua
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:

JavaScript
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.

  1. Your own NUI callback (same as LB). The page lives at https://cfx-nui-my_resource/..., so it can call the RegisterNUICallback handlers of its own resource:

    Lua
    RegisterNUICallback('save', function(data, cb) cb({ ok = true }) end)
    JavaScript
    const res = await RarxPhone.nui('save', { text })

    RarxPhone.nui(name, data) does fetch('https://my_resource/name') and returns the JSON. It goes straight to your resource; the phone is not involved.

  2. Through the phone: RarxPhone.send(data) calls onMessage(data) of the registration (client) or onMessage(src, data) (server, if registered on the server). The local event rarx_phone:customApp is 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:

Lua
AddEventHandler('rarx_phone:customApp', function(identifier, action, data) end)
Lua
AddEventHandler('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)#

HTML
<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:

JavaScript
{ __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:

JSON
{
  "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:

CSS
: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 in rarx_phone_apps. defaultApp apps 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 the iframe: the page handles its own navigation.
  • On close the iframe is unloaded; when the app opens again the page reloads (onOpen in Lua, ready in the SDK).
  • Game key blocking: with the SDK, focusing an editable field enables setTyping and leaving it releases the keys. Without the SDK, keys are blocked while focus is inside the iframe until 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> (or nui://<resource>) of an existing resource. This is validated in Lua and again in the UI.
  • The iframe uses sandbox="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.source is that app's iframe, and only the SDK actions listed above (with size and rate limits). Anything else returns err_not_allowed. The iframe cannot call the phone's RPCs through the bridge.
  • The phone ignores { action, data } messages coming from any child iframe (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.

fxmanifest.lua
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'
client.lua
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)
html/index.html (summary)
<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>