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-discoveredLoad 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)
-- 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/createdAlso available (server):
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)
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.profileClient 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).
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 modeWithout 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:
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:
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).
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 datainit:{ 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 rpcphone.init.push:{ event = 'messages:new', data = ... }. Core pushesphone:time{ hours, minutes, msPerGameMinute }on open and once a real minute while open (extrapolate in between), andphone:weatherfrom the GTA weather type (Config.Weatherlabel / °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;openends it too.notify:{ id, app, title, body, icon?, data?, sound, time (ms), peek, openKey? }.peek = truewhen 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
openfields{ locked?, app?, params? }(force lock screen / open an app). With a passcode the shell opens on the lock screen and callsphone.lockwhen closing (auto-lock). The open app stays mounted (state preserved) under the lock screen forConfig.KeepAppMinutes(default 5,0= close on lock). Exception: aclosewhile an app is in full-screen overlay mode (os.setFullscreen, e.g. the camera) only hides the phone frame — no auto-lock, and the nextopenstays unlocked.
Shell push events (handled by the shell itself):
| event | data | effect |
|---|---|---|
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:settings | partial settings | merged into the settings store |
phone:apps | string[] | 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)
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 firstinit, 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 leafos/manifests.ts, the store never imports the registry, so there is nostore ↔ registry ↔ manifestimport 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 →onClickor openapp.timerSince/timerUntilmake the shell render a ticking mm:ss (compact trailing and expanded subtitle fallback) — don't re-set the activity every second.avatarrenders anAvatar(initials, person icon for numbers).expanded: truepins 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; aclosefrom Lua meanwhile doesn't auto-lock. Render the overlay with<FullscreenPortal>(@/ui, a layer over the whole viewport). Cleared withsetFullscreen(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,FullscreenPortalre-apply it (accentprop to override). Promise dialogs (confirm/prompt/actionSheet) use the open app's manifest accent oropts.accent. Avatar nameshows initials for names and a person icon for labels without letters (phone numbers).ListRowwithonClickis adiv role="button"(rows may contain their own buttons).- Deep links / params:
openApp(id, params)always mounts a fresh app instance (newkey, nav stack reset), also when that app is already open — so apps readuseOS().paramsonce on mount and never see params change while mounted. Notification taps (banner, lock screen, notification center) callopenApp(n.app, n.data). Share intents:messages{ text?, attachImage?, number? | threadId? },chirp{ attachImage? , compose? },notes{ appendText }. useOS()additionally exposesserverId,config,installed,catalog,params(params passed toopenAppfor 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 acceptstruefor a dot badge.useNav()also hasreplace(node),reset(),depth. Edge swipe from the left pops.- Manifest optional fields:
description,category: 'app'|'game'(App Store). TextField/TextArea/SearchFieldonChangereceive the string value, not the event.ProgressBar.valueis 0–1.StatusPill.tonealso 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 assettings.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¶ms={"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
| Namespace | Area / 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):
| Method | Params → 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 passluac -p. - Every server handler validates
paramstypes 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
anyin exported APIs.npm run buildandnpm run typecheckmust pass.