Skip to content

Architecture & contracts ​

This page is the contract between all parts of the resource. Everybody builds against it. The source lives in the resource at docs/ARCHITECTURE.md. If you change a contract, change that file in the same commit and sync this page.

Visual source of truth: mockups/*.html, mockups/phone.css, mockups/renders/*.png.

1. Repository layout & ownership ​

fxmanifest.lua                     (core)  load order is fixed, see below
shared/lib/00_phone.lua            (core)  `Phone` global + Phone.Bridge.shouldLoad
shared/lib/*.lua                   (core)  shared helpers (json-safe utils, number format)
shared/config.lua                  (core)  Config.* core settings
shared/config/<area>.lua           (apps)  Config.<Area> = {...}  one file per app area
bridge/framework/client/1_qbx.lua 2_qb.lua 3_esx.lua
bridge/framework/server/0_common.lua (helpers, Phone.BridgeUtil) 1_qbx.lua 2_qb.lua 3_esx.lua
bridge/voice/server/1_pma.lua 2_saltychat.lua 3_yaca.lua
client/core/NN_*.lua               (core)  open/close, prop+anim, NUI bridge, client RPC
client/apps/<area>.lua             (apps)  client-side parts of an app (camera, waypoint…)
server/core/NN_*.lua               (core)  RPC dispatcher, players, numbers, notifications, schema
server/apps/<area>.lua             (apps)  server handlers + schema for an app area
sql/<area>.sql                     (all)   reference copy of the tables each area creates
web/                               Vite + React 18 + TypeScript (strict) NUI
web/src/os/                        (shell) lock screen, home, dock, control center, island, notifications, router
web/src/ui/                        (shell) iOS component kit
web/src/lib/                       (shell) rpc, push events, nui helpers, mock backend, stores
web/src/apps/<id>/                 (apps)  one folder per app, auto-discovered

Load order (fxmanifest): shared lib → shared config → framework bridge → core → apps. Bridge files are numbered so auto detection prefers qbx over qb (qbx also provides qb-core). Never edit another area's files. Shared files that would conflict are avoided by design: the web app registry and mock backend use import.meta.glob, DB tables are created by each server app file itself, config is split per area.

2. Lua: core API (server) ​

lua
-- RPC: called from the NUI (via client) → server. Return a value, or nil + error string.
Phone.rpc.register('messages.send', function(src, params) ... return result end)
-- errors: return nil, 'not_enough_money'   (NUI receives { ok=false, error='not_enough_money' })
-- optional 3rd arg: { public = true } (allowed while passcode-locked, Config.EnforcePasscode)
--                   { noItem = true } (allowed without the phone item, Config.RequireItem)
-- The dispatcher already guarantees: params is a table, the player is loaded (Phone.getPlayer(src) ~= nil),
-- rate limit (Config.RateLimit), handler errors are caught (→ 'internal_error').
-- Dispatcher errors the NUI may see: unknown_method, rate_limited, not_ready, invalid_params, no_player, no_phone, locked

Phone.push(src, 'messages:new', data)        -- server → one player's NUI (event name 'app:event')
Phone.pushNumber(number, event, data)        -- to whoever currently holds that number (if online)
Phone.notify(src, { app='messages', title='Tony C.', body='...', icon=nil, data={...}, sound=true })
Phone.notifyNumber(number, notification)     -- same, by phone number (no-op if offline)
Phone.pushAll('chirp:new', data)             -- broadcast to every player's NUI (Phone.push(-1, ...) still works)
Phone.notifyAll(notification, filter?)       -- every player with a loaded phone; filter(src, player) -> bool; returns count

Phone.getPlayer(src)        -- { source, identifier, number, name = 'First Last', job = {name,label,grade,onduty} } or nil
Phone.getSourceByNumber(n)  -- source or nil
Phone.getNumber(identifier) -- phone number string, created on first use (Config.NumberFormat)
Phone.getIdentifierByNumber(n)

Phone.schema(sqlString)     -- run CREATE TABLE IF NOT EXISTS ... once on start (await, before RPCs are served)
                            -- several statements per string are fine if each ends with ';' at line end
Phone.onPlayerReady(function(src, player) end) -- after the phone profile is loaded/created

Also available (server):

lua
Phone.v.str(v, min, max)  Phone.v.int(v, min, max)  Phone.v.num(v, min, max)  Phone.v.bool(v)
Phone.v.id(v, maxLen)     Phone.v.number(v)         -- validators: return the value or nil (shared)
Phone.json.decode(str, fallback)  Phone.json.encode(v)  Phone.copy(t)  Phone.debug(...)  Phone.warn(fmt, ...)
Phone.fw('getMoney', src, 'bank')   -- pcall-wrapped Phone.Framework call (nil on error / no bridge)
Phone.onReady(cb)                    -- after the schema ran (core uses it; apps rarely need it)
Phone.getSettings(src)               -- merged settings (defaults + player)
Phone.canUseApp(src, appId)          -- true | false, 'requires_item' | 'requires_job' (Config.StoreApps)
Phone.hasPhoneItem(src)              -- Config.RequireItem check (cached 5 s)
Phone.sha256(str)                    -- hex digest (shared)

Server events: np_phone:playerReady (src, player), np_phone:playerUnloaded (src, identifier, number). Server exports: notify(src, n), notifyNumber(number, n), push(src, event, data), pushAll(event, data), notifyAll(n), getNumber(src), getNumberByIdentifier(id), getSourceByNumber(number), getIdentifierByNumber(number), close(src), registerStoreApp(entry) (see §6 App Store). (sendMail is owned by the mail area.)

Database: oxmysql (MySQL.query.await, MySQL.insert.await, ...). All tables prefixed phone_. Core owns phone_profiles (identifier PK, number UNIQUE, settings LONGTEXT/JSON, passcode, created_at) and phone_apps (identifier, app, installed_at) (installed App Store apps). Every money / vehicle / item action is validated on the server. The client only requests.

3. Lua: core API (client) ​

lua
Phone.clientRpc.register('gps.setWaypoint', function(params) SetNewWaypoint(params.x, params.y) return true end)
-- NUI rpc methods that have a client handler are handled locally; everything else goes to the server.
Phone.isOpen()            Phone.open()            Phone.close()
Phone.sendNui(type, data) -- SendNUIMessage({ type = type, data = data })
Phone.setPose('text'|'call')               -- holding animation; also NUI client rpc `phone.pose`
local ok, data = Phone.serverRpc(method, params)  -- client → server rpc (thread only, 10 s timeout)
Phone.fw('isDead')  Phone.state.open  Phone.settings  Phone.profile

Client events: np_phone:opened, np_phone:closed. Client exports: open(), close(), isOpen(), setPose(pose), ring(opts), stopRing(id?), isRinging().

Ring / peek mode (client/core/05_ring.lua): the phone slides up partly without NUI focus (e.g. an incoming call while the phone is put away) and key mappings answer it (Config.CallKeys = { accept = 'Y', decline = 'BACK' }, rebindable in GTA key bindings; commands np_phone_accept / np_phone_decline, idle cost 0).

lua
Phone.ring({ id = 'call', app = 'phone', title = 'Tony C.', subtitle = 'Incoming call',
             onAccept = fn(id)?, onDecline = fn(id)?, openOnAccept = true, timeout = ms? }) -> boolean
Phone.stopRing(id?)  Phone.isRinging() -> id | nil      -- opening the phone ends ring mode

Without callbacks a key press is forwarded to the NUI as push phone:ringKey { id, action = 'accept'|'decline' }; openOnAccept then opens the phone. The NUI can start/stop it itself with the client rpc phone.ring (the Phone app's service does this for incoming calls, so client/apps/calls.lua needs no ring code). While ringing, peek banners of the ringing app (app, default phone) are not shown (the phone itself is visible).

4. Framework bridge interface (Phone.Framework) ​

Each bridge file starts with if not Phone.Bridge.shouldLoad('framework', 'qbx', 'qbx_core') then return end (qb: qb-core, esx: es_extended) and then sets Phone.Framework = { ... }.

Server:

lua
getIdentifier(src)            -- citizenid / esx identifier
getName(src)                  -- 'First Last'
getJob(src)                   -- { name, label, grade, gradeLabel, onduty }
getMoney(src, account)        -- account: 'bank' | 'cash'
removeMoney(src, account, amount, reason) -> boolean
addMoney(src, account, amount, reason)    -> boolean
addMoneyOffline(identifier, account, amount, reason) -> boolean  -- for transfers to offline players
getSourceByIdentifier(identifier)
hasItem(src, item)            -> boolean
addItem(src, item, count, metadata?)    -> boolean  -- ox_inventory if started (CanCarryItem first), else qb-inventory /
                                                     -- Player.Functions.AddItem (qb, qbx) or xPlayer.addInventoryItem (esx, no metadata)
removeItem(src, item, count, metadata?) -> boolean  -- only when the player has `count`; same inventory order
setDuty(src, onDuty)          -> boolean            -- qb/qbx Player.Functions.SetJobDuty; esx >= 1.10 xPlayer.setJob(name, grade, onDuty)
                                                     -- (false on older ESX without job.onDuty)
getVehicles(identifier)       -- { { plate, model, garage, state ('garaged'|'out'|'impounded'), fuel, engine, body } }
getCharacterInfo(src)         -- { firstname, lastname, dob, gender, nationality, stateId, licenses = {driver=true,...} }
onPlayerLoaded(cb(src)) / onPlayerUnloaded(cb(src))

Client:

lua
isLoaded()   isDead()   isCuffed()
onLoaded(cb) onUnloaded(cb)

5. Voice bridge interface (Phone.Voice, server-side) ​

if not Phone.Bridge.shouldLoad('voice', 'pma-voice', 'pma-voice') then return end (saltychat: saltychat, yaca: yaca-voice).

lua
Phone.Voice.startCall(callId, sources)   -- put all sources in one call
Phone.Voice.endCall(callId, sources)
Phone.Voice.setSpeaker(src, enabled)     -- optional, may no-op
Phone.Voice.setMuted(src, muted) -> bool  -- mute the player's mic in the call; true only when actually applied
Phone.Voice.canMute                       -- true when setMuted does something
Phone.Voice.name                          -- 'pma-voice' | 'saltychat' | 'yaca'

startCall adds sources to call callId (creating it; may be called again to add participants). endCall removes sources from it; pass every participant to end the call. callId may be a number or string. If no voice bridge loaded, a no-op fallback (name = 'none') is installed by core and calls still work as UI-only. setMuted support: YACA muteOnPhone(source, state) (applied); pma-voice and SaltyChat have no server export for it (README server exports checked) → returns false, mute stays UI-only. pma-voice call channels are shared with every other setPlayerCall user, so the bridge uses Config.VoiceCallChannelOffset (default 10000) + numeric call id (string ids: a separate range 1,000,000 above the offset).

6. NUI protocol ​

Lua → NUI: SendNUIMessage({ type = 'open'|'close'|'init'|'push'|'notify', data = ... })

  • open: { serverId }; close: no data
  • init: { profile = { number, name }, settings, serverId, locale, apps = {installed App Store ids}, hasPasscode, locked, config = { framework, voice, showServerId, serverIdOnStatusBar, requireItem, walkMode, openKey, numberFormat, uploadProvider, keepAppMinutes, callKeys } } Sent after the player loaded, on resource start, and after a character switch. The NUI can also call rpc phone.init.
  • push: { event = 'messages:new', data = ... }. Core pushes phone:time { hours, minutes, msPerGameMinute } on open and once a real minute while open (extrapolate in between), and phone:weather from the GTA weather type (Config.Weather label / °C map, Config.WeatherCity) on open and when it changes while open.
  • ring: { active, id?, app?, title?, subtitle?, keys? = { accept, decline } } ring / peek mode (§3): phone slides up partly without focus, key hints next to it. { active = false, id? } ends it; open ends it too.
  • notify: { id, app, title, body, icon?, data?, sound, time (ms), peek, openKey? }. peek = true when the phone is closed: show a compact banner without focus (the client already played the sound unless silent/dnd/airplane). The shell renders peek banners bottom-right with an "open" key hint (openKey), outside the phone frame.
  • The shell also accepts optional open fields { locked?, app?, params? } (force lock screen / open an app). With a passcode the shell opens on the lock screen and calls phone.lock when closing (auto-lock). The open app stays mounted (state preserved) under the lock screen for Config.KeepAppMinutes (default 5, 0 = close on lock). Exception: a close while an app is in full-screen overlay mode (os.setFullscreen, e.g. the camera) only hides the phone frame — no auto-lock, and the next open stays unlocked.

Shell push events (handled by the shell itself):

eventdataeffect
phone:time{ hours, minutes, msPerGameMinute? }in-game clock for status bar / lock / home (real time until the first push)
phone:weather{ city?, temp, condition, icon?, high?, low?, forecast?: {hour, icon, temp}[] }weather widgets; icon/condition = GTA weather type (EXTRASUNNY, RAIN, …) or a FA class
phone:settingspartial settingsmerged into the settings store
phone:appsstring[]installed App Store ids changed
phone:badge{ app, count }badge (count 0 clears)
phone:ringKey{ id, action }ring-mode key press (Lua, §3); handled by the service that started the ring

Settings keys the shell reads/writes (settings.update partials; unknown keys rely on Config.Settings.allowUnknown): wallpaper (clouds|storm|dusk|midnight|aurora|graphite or an image URL), theme (shell renders dark only), scale (0.8–1.2), streamerMode, hideCallerId, dnd, airplane, wifi, bluetooth, cellular, shareLocation, brightness (0–1), volume (0–1), ringtone, homeOrder (flat home slot list, '' = gap), caseColor (silver|black|pink|red|blue|gold|green), showLabels, notifications ({ [appId]: false } mutes). settings.passcode (boolean) is derived from init.hasPasscode and never sent.

Optional RPCs the shell calls and tolerates failing: phone.radio { enabled?, channel? } → { enabled, channel, talking? } (Control Center tile), wallet.summary → { bank, cash?, today? } and calendar.next → { title, time?, location?, date? } | null (lock / home widgets; both registered { public = true } so the lock screen works with Config.EnforcePasscode, reloaded after unlock and on wallet:update / calendar:update / calendar:invite).

App Store (server/core/06_apps.lua): the catalog is Config.StoreApps + apps registered at runtime with Phone.registerStoreApp(entry) / exports.np_phone:registerStoreApp(entry), plus installed ids. An entry is a gate (item, job (string or list), grade, onduty) and, for third-party apps without a web manifest, a listing (name, icon, tint, description, url, featured, category) — those open url in an iframe. apps.list passes all of it through; reason is a code (requires_item / requires_job) the App Store turns into text with item / job.

NUI → Lua (fetch https://np_phone/<name>, JSON body, JSON reply):

  • rpc { method, params } → { ok: true, data } | { ok: false, error }
  • close {}; focus { keyboard: boolean } (walk mode: release keyboard focus while not typing)

7. Web: platform API (implemented by the shell, used by apps) ​

ts
import { rpc, usePush, useRpc } from '@/lib/rpc'
await rpc<Message[]>('messages.list', { threadId })   // throws RpcError on { ok:false }
usePush<Message>('messages:new', m => ...)             // subscribe to pushes
const { data, loading, error, reload } = useRpc<T>('garage.list', params)

import { useOS } from '@/os'
const os = useOS()
os.openApp('messages', { threadNumber: '555-0142' })  os.closeApp()  os.goHome()
os.notify({ app, title, body })                       // local notification
os.island.set('groups', { icon, tint, title, subtitle, trailing, app })  os.island.clear('groups')   // see 7.1
os.setFullscreen(true)                                // full-screen overlay mode (camera HUD), see 7.1
os.profile  // { number, name }   os.settings / os.updateSettings(partial)
os.setBadge(appId, count)

// App registration: web/src/apps/<id>/manifest.ts (auto-discovered)
import { defineApp } from '@/os/apps'
export default defineApp({
  id: 'messages', name: 'Messages',
  icon: 'fa-solid fa-comment-dots', tint: 'gray',        // tint ∈ AppTint
  component: () => import('./App'),                       // lazy, default export
  placement: { dock: 1 } | { home: 3 } | 'store',         // dock slot, home grid index, or App Store only
  accent?: string,                                        // in-app accent override
  service?: () => import('./service'),                    // background service, see 7.1
})

// Browser dev mock backend: web/src/apps/<id>/mock.ts (auto-discovered, only in dev / non-FiveM)
import { mock, mockPush } from '@/lib/mock'
mock('messages.list', params => [...])

// In-app navigation stack
import { useNav } from '@/os/nav'
const nav = useNav(); nav.push(<ThreadScreen id={1} />); nav.pop()

AppTint = 'gray'|'red'|'orange'|'rust'|'pinkf'|'olivef'|'rose'|'green'|'blue'|'gold'|'violet'|'dark' (same gradients as mockups/phone.css .ic.*).

UI kit (@/ui): AppScreen (status-bar safe area + accent prop), NavBar {back?, title?, right?}, LargeTitle, SearchField, Section {title}, List, ListRow {leading, title, subtitle, meta, chevron, onClick, trailing}, Avatar {name? icon? tint? size}, AppIcon {icon, tint, size:'lg'|'md'|'sm'|'xs', badge?}, Button {variant:'fill'|'tinted'|'plain', size, icon}, Chip {active}, Toggle, Segmented, TabBar {tabs:{id,label,icon}[], value, onChange}, Sheet {open,onClose,title}, ActionSheet, confirm()/prompt() (promise dialogs), TextField, TextArea, ProgressBar {value, color}, StatusPill {tone:'green'|'orange'|'red'}, EmptyState {icon,title,body}, Spinner, formatMoney, formatTime, formatRelative.

Styling: CSS modules + CSS variables from web/src/ui/theme.css (ported from mockups/phone.css). Icons: Font Awesome Free 6 (npm @fortawesome/fontawesome-free, CSS classes). Fonts: system (-apple-system, system-ui), clock uses Montserrat (bundled via @fontsource/montserrat; NUI must work offline).

Dev mode: npm run dev in web/ shows the phone centered in a browser with the cloud wallpaper. URL params for screenshots: ?app=<id> opens an app, ?locked=1 shows the lock screen, ?cc=1 opens Control Center.

7.1 Shell implementation notes (additions to the API above) ​

  • Background services: service: () => import('./service') in the manifest; the module's default export (os: OSHandle) => void | (() => void) is started once by the shell (os/services.ts) after the first init, whether or not the app is ever opened (push listeners for incoming calls, live activities, alarms). Used by phone (calls, ring mode, contact shares), groups (job live activity) and clock (timer/alarms). Manifest modules must not have side effects; they may import from @/os (the registry fills the leaf os/manifests.ts, the store never imports the registry, so there is no store ↔ registry ↔ manifest import cycle any more).
  • Dynamic Island IslandActivity = { icon?, avatar?: { name?, src?, tint? }, tint?, title?: ReactNode, subtitle?: ReactNode, timerSince?: epochMs, timerUntil?: epochMs, trailing?, expanded?, actions?, onClick?, app?, params? }. iOS behaviour: tap compact → expand (title / subtitle / actions; collapses after 6 s or a tap elsewhere), tap the expanded card → onClick or open app. timerSince / timerUntil make the shell render a ticking mm:ss (compact trailing and expanded subtitle fallback) — don't re-set the activity every second. avatar renders an Avatar (initials, person icon for numbers). expanded: true pins the large card (incoming call). Expanded activities win over compact ones, otherwise the newest wins.
  • Full-screen overlay mode: os.setFullscreen(true) hides the phone frame but keeps the NUI and the app mounted; a close from Lua meanwhile doesn't auto-lock. Render the overlay with <FullscreenPortal> (@/ui, a layer over the whole viewport). Cleared with setFullscreen(false) or when the app unmounts. The camera HUD uses it.
  • Accent in portals: the shell provides the app accent via AccentContext (<AppScreen accent> overrides it); Sheet, ActionSheet, ScreenPortal, FullscreenPortal re-apply it (accent prop to override). Promise dialogs (confirm / prompt / actionSheet) use the open app's manifest accent or opts.accent.
  • Avatar name shows initials for names and a person icon for labels without letters (phone numbers).
  • ListRow with onClick is a div role="button" (rows may contain their own buttons).
  • Deep links / params: openApp(id, params) always mounts a fresh app instance (new key, nav stack reset), also when that app is already open — so apps read useOS().params once on mount and never see params change while mounted. Notification taps (banner, lock screen, notification center) call openApp(n.app, n.data). Share intents: messages { text?, attachImage?, number? | threadId? }, chirp { attachImage? , compose? }, notes { appendText }.
  • useOS() additionally exposes serverId, config, installed, catalog, params (params passed to openApp for the current app), closePhone(), installApp(id), uninstallApp(id). import { os } from '@/os' is the non-reactive handle for timers / module code (os.state = store snapshot).
  • os.setBadge(appId, count) also accepts true for a dot badge.
  • useNav() also has replace(node), reset(), depth. Edge swipe from the left pops.
  • Manifest optional fields: description, category: 'app'|'game' (App Store).
  • TextField / TextArea / SearchField onChange receive the string value, not the event.
  • ProgressBar.value is 0–1. StatusPill.tone also accepts 'gray'.
  • Extra UI exports: NavButton, ChipRow, Slider, actionSheet() (promise), formatDate, formatDuration, formatPhone, initials, ScreenPortal, cx. mockDefault() registers a mock only if none exists.
  • Home grid: page 1 has 16 slots (reserved indices 0–13 like the reference), page 2 has 8 slots under the widgets (indices 14–21), further pages 24. Installed store apps take the first free slot from page 2. Long-press → edit mode (labels + jiggle, drag to swap slots, − removes store apps); order saved as settings.homeOrder.
  • Browser dev URL params besides ?app, ?locked, ?cc: ?time=09:14, ?page=2, ?edit=1, ?passcode=1234, ?streamer=1, ?banner=1, ?placeholders=1 (inert icons for reserved apps that don't exist yet), ?params=<json> (openApp params for ?app, e.g. ?app=messages&params={"text":"hi"}), ?call=1 (active call), ?stage=game (in-game stage: phone bottom-right with slide / ring / full-screen behaviour), ?closed=1 (start put away; ?stage=game&closed=1&incoming=1 = ring mode, Y / Backspace answer). Core client RPCs (gps.*, phone.flashlight, phone.pose, phone.close, phone.ring) have default mocks. Console / scripts: window.npDev = { nui, push, store, openApp } (dev only).

8. RPC namespaces ​

NamespaceArea / owner
phone.* settings.* profile.* apps.*core / shell (settings, installed apps, passcode)
contacts.* calls.* messages.*comms
wallet.* garage.* groups.* mail.* info.*city
chirp.* yp.* photos.* camera.* notes.* calendar.* maps.* clock.*social & media
gps.* (client-only: waypoint, coords)core

Core RPCs (server unless noted):

MethodParams → result
phone.init→ init payload (§6)
phone.canOpen→ true | error no_phone (used by the client before opening)
settings.get→ settings (defaults merged)
settings.update{ key = value, ... } (partial) → full settings. Keys/types per Config.Settings.keys; errors invalid_key, invalid_value, too_large
phone.setPasscode{ passcode = '1234' | '' | false, current? } → { hasPasscode }; current required when one is set
phone.unlock{ passcode } → true | wrong_passcode | too_many_attempts (5 tries, then 60 s)
phone.lock→ true
apps.list→ { installed = {ids}, apps = { { id, installed, locked, reason?, item?, job? } } } (gated + installed)
apps.install / apps.uninstall{ id } → { installed = {ids} }; install errors requires_item, requires_job, too_many_apps
gps.setWaypoint (client){ x, y } → true
gps.getCoords (client)→ { x, y, z, street, zone }
phone.flashlight (client){ enabled? } (toggle if omitted) → { enabled }
phone.pose (client){ pose = 'text' | 'call' } → true
phone.close (client)→ true
phone.ring (client){ active, id?, app?, title?, subtitle?, openOnAccept? } → boolean (ring / peek mode, §3)

Push events use '<area>:<event>', e.g. calls:incoming, messages:new, groups:update.

9. Conventions ​

  • Lua 5.4, 2-space indent, no CfxLua-only syntax (no backtick hashes; use joaat), must pass luac -p.
  • Every server handler validates params types and ownership; never trust amounts/ids from the client.
  • No polling while the phone is closed. Idle resmon target 0.00 ms.
  • TS strict, no any in exported APIs. npm run build and npm run typecheck must pass.

np_* FiveM resources