# Custom apps (SDK)

Optional developer reference for building custom phone apps and Browser sites.

How another FiveM resource adds an app, widgets, notifications, and deep links
to NovaOS. The release ships two complete SDK resources, each as its own zip
and both at version 1.0.1: `nova_example_app`, a small working example, and
the Browser add-on (`nova_browser`), a larger one that also uses mobile data
and per-phone storage. The example app is a how-to rather than a feature, so
it ships unencrypted (its `fxmanifest.lua` lists `'*.lua'` under
`escrow_ignore`): read it and copy from it.

- [How external apps work](#how-external-apps-work)
- [Registering an app](#registering-an-app)
- [The app page and the SDK](#the-app-page-and-the-sdk)
- [Mobile data and app storage](#mobile-data-and-app-storage)
- [Permissions](#permissions)
- [Notification channels](#notification-channels)
- [Deep links](#deep-links)
- [Widgets](#widgets)
- [Server exports reference](#server-exports-reference)
- [Events](#events)
- [Browser add-on: in-game sites](#browser-add-on-in-game-sites)
- [Security model](#security-model)
- [Icons](#icons)

## How external apps work

```text
your resource (server)                wd_phone (server)
  RegisterApp{ id, ui, permissions } ──► registry (memory; cleared when your resource stops)
                                          │ broadcast to every client
                                          ▼
player's phone (NUI) ── Nova Store ──► install / uninstall / update (phone_installed_apps)
  │
  └─ opens <iframe sandbox src="https://cfx-nui-<your_resource>/<ui>">
        │  window.postMessage  ◄──►  NovaSDK (sdk/nova-sdk.js)
        │                            origin + source checked on every message
        └─ fetch('https://<your_resource>/<callback>')  ──►  your client.lua NUI callbacks
```

- Your UI is a page **served by your own resource**. It runs in a sandboxed
  iframe (`allow-scripts allow-same-origin allow-forms`) and cannot reach the
  phone's DOM, stores, or NUI callbacks.
- Your UI talks to **your own** client scripts with normal NUI callbacks
  (`https://<your_resource>/<name>`), and to NovaOS only through the SDK.
- Install state, permission grants, notification-channel preferences and app
  storage are per phone and persist across restarts. Your app definition does
  not persist: register it every time your resource (or Wolfden Phone) starts.

## Registering an app

```lua
-- server.lua in your resource
local function register()
    local ok, reason = exports.wd_phone:RegisterApp({
        id = 'novanews',                 -- letter first, then letters/digits/_ (2-32)
        label = 'Nova News',             -- 24 chars max
        icon = 'newspaper',              -- a registered icon name (see Icons)
        color = '#e5484d',
        version = '1.1.0',               -- MAJOR.MINOR.PATCH; raise it to offer an update
        category = 'news',               -- Nova Store grouping
        developer = 'Nova Example Co.',
        description = 'Headlines from around Los Santos.',
        ui = 'html/index.html',          -- relative .html/.htm path inside THIS resource
        permissions = { 'notifications', 'location' },
        channels = {
            { id = 'breaking', label = 'Breaking news',
              defaults = { enabled = true, banner = true, sound = true, lockscreen = true } },
        },
        deepLinks = { 'story/<id>' },    -- documentation shown to players/admins
        preinstalled = false,            -- true installs on currently open phones
    })
    if not ok then print('RegisterApp failed: ' .. tostring(reason)) end
end

AddEventHandler('onResourceStart', function(resource)
    if resource == GetCurrentResourceName() or resource == 'wd_phone' then
        SetTimeout(500, register)        -- wd_phone may (re)start after you
    end
end)
```

Your `fxmanifest.lua` must list the UI files:

```lua
files { 'html/index.html', 'html/*.js', 'html/*.css' }
```

Rules enforced by `RegisterApp` (it returns `false, reason` otherwise):

| Field | Rule |
| --- | --- |
| `id` | Must not collide with a built-in app. While one resource has it registered, no other resource can register it. |
| `ui` | A relative path (128 characters max) without `..`, `/` prefix, or scheme. Only `.html`/`.htm`. Served from `https://cfx-nui-<resource>/<ui>`. |
| `permissions` | Only `notifications`, `location`, `contacts`, `media`. |
| `channels` | Up to 8, ids match `^[a-z0-9_]{2,32}$`, labels 40 characters. `default` always exists implicitly. `defaults` fields left out are on. |
| Other fields | `description` 240 characters, `category` 24, `developer` 40 (default: your resource name), up to 16 `deepLinks`. An invalid `version` becomes `1.0.0`, an invalid `color` `#5e5ce6`, an invalid `icon` `app-window`. |
| Limits | `Config.AppPlatform.external.maxApps` (default 40); `allowedResources` can restrict who may register; `enabled = false` refuses every app; `allowPreinstall = false` ignores `preinstalled`. |

Operators can also install or remove an app on a known device:

```lua
exports.wd_phone:InstallApp(deviceId, 'novanews')   --> ok, errorCode
exports.wd_phone:UninstallApp(deviceId, 'novanews')
```

The phone model's `apps` whitelist (for example the Wolfden Phone Secure model) applies
to external apps too. An app that is not on the whitelist is "Not on this model"
in the Store.

### Versions and updates

Raise `version` and re-register. Devices that installed an older version see
**Update** in the Nova Store and a single "updates available" notification the
next time the phone opens (`Config.AppPlatform.notifyUpdates`). The installed
version is informational for your app; read it from `NovaSDK.getContext().version`
if you need migrations.

## The app page and the SDK

Include the SDK from Wolfden Phone:

```html
<script src="https://cfx-nui-wd_phone/sdk/nova-sdk.js"></script>
<script>
  // Attach listeners first: the phone delivers a pending deep link right after
  // the ready() handshake, and pushes any later ones while the app is open.
  NovaSDK.on('context', (c) => { /* the phone's theme changed: same shape as getContext() */ });
  NovaSDK.on('deepLink', (route) => { /* { path: ['story','42'], query: {}, uri } */ });
  NovaSDK.on('message', (data) => { /* from exports.wd_phone:SendAppMessage */ });

  (async () => {
    const ctx = await NovaSDK.ready();          // { appId, version, locale, theme, ..., route }
    document.documentElement.dataset.theme = ctx.theme;
  })();
</script>
```

| Method | Permission | Result |
| --- | --- | --- |
| `ready()` | none | Handshake. Context below plus `route` (the deep link that opened the app, or `null`). Call once, after attaching listeners. |
| `getContext()` | none | `{ appId, version, locale, theme, reduceMotion, textSize, deviceModel, secureDevice, permissions }` |
| `getPermissions()` | none | `{ [permission]: 'granted' \| 'denied' \| 'ask' }` |
| `requestPermission(name)` | none | Prompts on the phone once; `{ granted }` |
| `notify({ title, content, channel, deepLink, persistent })` | `notifications` | `{ delivered }`. Title 64 / content 256 characters. At most `notifyMax` (6) per `notifyWindowSeconds` (60) per player (`Config.AppPlatform.sdk`). |
| `getContacts()` | `contacts` | `{ contacts: [{ displayName, phoneNumber, isFavorite }] }` (the phone owner's contacts, max 200) |
| `getLocation()` | `location` | `{ x, y, z, street, zone }` of the player |
| `pickMedia({ max })` | `media` | Opens the gallery picker (`max` 1-4); `{ media: [{ mediaId, url, thumbnailUrl, width, height }] }` |
| `openDeepLink('nova://…')` | none | Routes a validated link; `{ opened }` |
| `close()` | none | Closes the app |
| `useData(kind)` | none | Uses the phone's mobile data for one `request` / `page` / `media`; `{ online }`. See below. |
| `getItem(key)` | none | `{ key, value }` from this phone's storage for your app (`value` is `null` when unset) |
| `setItem(key, value)` | none | Stores any JSON value; `{ key, stored }` |
| `removeItem(key)` | none | `{ key, removed }` |
| `on(event, fn)` / `off(event, fn)` | none | Listen for `context`, `deepLink` and `message`. Returns `NovaSDK`. |
| `activity()` | none | Tells the phone the player is using your app, so Auto-Lock does not lock it. Clicks, keys, scrolling and mouse movement in your page are reported on their own; call it only for input your page cannot see, such as a nested frame. |

Every call returns a Promise that rejects with an `Error` whose `.code` is one of
`PERMISSION_DENIED`, `PERMISSION_UNKNOWN`, `RATE_LIMITED`, `APP_NOT_INSTALLED`,
`INVALID_LINK`, `INVALID_PAYLOAD`, `DATA_KIND_UNKNOWN`, `STORAGE_LIMIT`,
`UNKNOWN_METHOD`, `TIMEOUT` (15 s; `requestPermission` and `pickMedia` wait
2 minutes), `NOT_EMBEDDED` or `INTERNAL`, or (from `useData`) one of the offline
reasons below. A call that reaches the server can also fail the phone's own
checks (`NO_SESSION`, `NOT_POSSESSED`, `DEVICE_LOCKED`, `DEVICE_LOST`).

Messages are limited to 8 KB (`Config.AppPlatform.sdk.maxMessageBytes`); a
larger one is dropped and the call times out.

## Mobile data and app storage

### `useData(kind)`

Anything your app fetches "from the internet" in roleplay terms (a feed, a
web page, a photo) should cost the phone mobile data like the built-in apps.
Call `NovaSDK.useData(kind)` **before** loading it:

```js
try {
  await NovaSDK.useData('page');
  loadThePage();
} catch (e) {
  showOffline(e.message);   // e.code: AIRPLANE_MODE, DATA_OFF, NO_SIM, SIM_LOCKED,
}                            // LINE_SUSPENDED, NO_SERVICE, OUT_OF_DATA, NO_DATA, RATE_LIMITED
```

| Kind | Size (KB, `Config.MobileData.usage`) |
| --- | --- |
| `request` | `appRequest` (10) |
| `page` | `appPage` (400) |
| `media` | `appMedia` (600) |

The server decides the size from its own config
(`Config.AppPlatform.sdk.dataKinds` maps each kind to a usage key); your app
never reports one. A joined Wi-Fi network in range is free, exactly as for
built-in apps. Calls are rate limited per player
(`Config.RateLimits.platformData`) and the app must be installed on the phone.
`e.message` is already translated into the phone's language.

### `getItem` / `setItem` / `removeItem`

A small key/value store **per phone and per app** (`phone_app_storage`), for
data that should follow the phone item rather than the player's PC: history,
drafts, preferences. Values are any JSON (objects, arrays, strings, numbers,
booleans).

| Limit | Default (`Config.AppPlatform.sdk.storage`) |
| --- | --- |
| Keys | `[A-Za-z0-9_.-]`, 1-64 characters |
| Keys per app per phone | `maxKeys` = 32 |
| Value size | `maxValueBytes` = 6144 bytes of JSON (SDK messages are 8 KB) |

Storage is erased when the app is uninstalled from that phone and when the
phone is erased. It is not included in Nova Cloud backups or phone
transfers. Rate limit: `Config.RateLimits.platformStorage`. Never store
secrets: the player's own client can read and write it through the SDK.

## Permissions

- Declare every permission you might need in `RegisterApp`. Undeclared
  permissions are always denied.
- `requestPermission` shows a system prompt the first time; the answer is
  stored per device (`phone_app_permissions`). Players change it later in
  **Settings → Apps → your app** (allowing there shows the same prompt).
  The server raises the prompt, so an app frame cannot grant itself anything.
  A denied permission is not asked again by the app.
- An app ID belongs to the resource that first registered it. If another
  resource later registers the same ID, every phone's grants, notification
  channels and app storage for it are reset.
- Permission checks happen in the NUI **and** again on the server for anything
  that reads server data or delivers a notification.
- Built-in NovaOS apps are system apps and are not permission-gated.

## Notification channels

Every notification belongs to an app channel. Players toggle each channel
(enabled / banners / sound / lock screen) in **Settings → Apps**.

From your app page, use `NovaSDK.notify({ channel: 'breaking', ... })`. From
your server:

```lua
exports.wd_phone:SendNotification(source, {
    app = 'novanews',          -- your app id
    channel = 'breaking',      -- unknown channels fall back to 'default'
    title = 'Breaking',
    content = 'Storm warning issued for Blaine County',
    persistent = true,
    data = { deepLink = 'nova://novanews/story/43' },   -- tap target (validated)
})
```

It goes to the player's open phone, else the phone they last looked at,
else the first phone they carry (`nil` when they carry none).
A notification is **not delivered** when the app is not installed, when an
external app lacks the `notifications` permission, or when the channel is off.
`SendNotification` then returns `{ suppressed = true }`.

Built-in channels, for reference: `phone/missed`, `phone/voicemail`,
`messages/direct`, `messages/groups`, `bank/activity`, `bank/requests`,
`social/interactions`, `social/follows`, `snapgram/activity`,
`snapgram/messages`, `ember/likes`, `ember/matches`, `ember/messages`,
`mail/inbox`, `services/requests`, `calendar/reminders`, `clock/alarms`,
`clock/timers`, `marketplace/listings`, `business/orders`, `business/campaigns`,
`simManager/billing`, `store/updates`, `store/available`, `findmy/security`
and `findmy/accessories` (Locate), `news/breaking`, `garage/valet`,
`homes/keys`, `shade/dms`, `shade/mentions`, `bites/orders`, `bites/couriers`,
`rides/trips`, `rides/offers`, `racing/lobbies`, `racing/results`,
`racing/crew`, `racing/bets`, `clips/activity`, `clips/follows`, `clips/live`,
`clips/deals`.

## Deep links

`nova://<appId>/<segment>/<segment>?key=value`

| Limit | Value |
| --- | --- |
| Length | 512 characters |
| Segments | 8 including the app id; each `[A-Za-z0-9_.-]`, 64 max |
| Query | 12 parameters; keys `[A-Za-z0-9_]`, values 128 max, no `<`, `>` or control characters |

Built-in routes:

| Link | Opens |
| --- | --- |
| `nova://pay?account=<id>` (legacy) / `nova://bank/pay?account=<id>` · `nova://bank/request/<requestId>` | Nova Wallet pay screen, prefilled / a payment request |
| `nova://social/post/<postId>` · `nova://social/profile/<handle>` | Echo post / profile |
| `nova://snapgram/post/<id>` · `nova://snapgram/profile/<handle>` · `nova://snapgram/thread/<id>` · `nova://snapgram/activity` | Snapgram post / profile / DM thread / Activity |
| `nova://ember/chat/<matchId>` · `nova://ember/matches` · `nova://ember/likes` | Ember chat / Matches / Likes |
| `nova://news/article/<articleId>` | A News article |
| `nova://shade/channel/<id>` · `nova://shade/dm/<threadId>` · `nova://shade/room/<roomId>` · `nova://shade/join/<code>` | Shade channel / DM / a room the phone belongs to / Rooms with the invite code filled in (joining is a tap) |
| `nova://clips/clip/<id>` · `nova://clips/profile/<handle>` · `nova://clips/tag/<tag>` · `nova://clips/activity` · `nova://clips/requests` · `nova://clips/live/<id>` · `nova://clips/creator` · `nova://clips/deals` · `nova://clips/upload?caption=<text>` | Clips clip / profile / hashtag / Inbox / follow requests / a live stream / Creator tools (`creator` and `deals` open the same screen) / the new-clip composer with the caption filled in (the player still picks the video) |
| `nova://contacts/search` | Contacts with search open |
| `nova://music/now` | Music's Now Playing |
| `nova://notes/new` | A new note |
| `nova://clock/timer` | Clock's Timer tab |
| `nova://messages/conversation/<id>` · `nova://messages/compose?to=<n>` (`&body=<text>` types a draft in, never sends) | Messages |
| `nova://phone/dial?number=<n>` · `nova://phone/voicemail` | Phone keypad / voicemail |
| `nova://maps/location?x=&y=&label=` | Maps focused on a point |
| `nova://mail/message/<id>` | A mail |
| `nova://services/service/<id>` | Services, preselected |
| `nova://store` · `nova://store/app/<appId>` · `nova://store/updates` · `nova://store/install/<appId>` | Nova Store (`install` opens the app's page, installs it through the normal Store install and opens it; an installed app just opens) |
| `nova://settings/<section>` | Settings section: `general`, `display`, `sounds`, `notifications`, `communication`, `security`, `home`, `device`, `accessibility`, `apps`, `backup`, `account`, `wifi`, `bluetooth`, `hotspot`, `battery`, `controlCenter`, `cellular` |
| `nova://rides/trip` · `nova://rides/drive` · `nova://rides/history` | Rides: your trip / Drive tab / History |
| `nova://racing/lobby/<raceId>` · `nova://racing/join/<code>` · `nova://racing/track/<code>` · `nova://racing/result/<raceId>` · `nova://racing/crew/<crewId>` · `nova://racing/live` · `nova://racing/races` · `nova://racing/tracks` · `nova://racing/ranks` · `nova://racing/profile` · `nova://racing/history` | Racing: a lobby / the lobby with that invite code (joining is a tap) / a track by its share code / a race result / a crew / your live race / a tab / your race history |
| `nova://browser/site/<domain>/<path>` · `nova://browser/open?url=<address>` | The Browser add-on, when installed |
| `nova://<yourApp>/<anything>` | Your app; the route arrives as the SDK `deepLink` event |

A link to an app that is not installed opens its Nova Store page (nothing
happens on a phone without the Nova Store). A link to an app the server
currently hides for that player, such as Racing without its Racing USB, does
nothing. `nova://` links in Messages and Mail render as tappable chips.

Server helpers:

```lua
exports.wd_phone:OpenDeepLink(source, 'nova://novanews/story/42')   --> boolean
exports.wd_phone:BuildDeepLink('maps', { 'location' }, { x = 12.5, y = -40, label = 'Pier' })
```

`OpenDeepLink` routes the link on the player's phone screen; it does not open
the phone.

## Widgets

```lua
exports.wd_phone:RegisterWidget({
    id = 'novanews_headlines',
    label = 'Headlines',
    icon = 'newspaper',
    app = 'novanews',                    -- only offered/kept where the app is installed
    deepLink = 'nova://novanews/story/42',
    layout = 'list',                     -- 'value' (default) | 'list' | 'progress'
    sizes = { 'small', 'medium' },
    defaultSize = 'medium',
})

exports.wd_phone:UpdateWidget('novanews_headlines', {
    items = { { label = 'Harbour bridge reopens', value = '#41' } },  -- list layout, max 5
    detail = 'Updated just now',
}, target)                               -- a source, or omit/-1 for everyone

exports.wd_phone:UnregisterWidget('novanews_headlines')
```

Data fields: `value`, `status`, `detail`, `unit` (strings ≤ 64 or numbers),
`progress` (0–1, progress layout), `color` (`#rrggbb`), `items` (≤ 5 label/value
pairs), `deepLink`. Payloads over 2 KB are rejected. Only the registering
resource may update or unregister a widget. Widgets are removed when your
resource stops and from the home screen when the owning app is uninstalled.

## Server exports reference

| Export | Purpose |
| --- | --- |
| `RegisterApp(def)` → `ok, reason` | Register/replace your external app |
| `UnregisterApp(appId)` | Remove it (automatic on resource stop) |
| `GetInstalledApps(source)` | `{ { appId, version, source } }` for the phone the player has open (`nil` when none is open) |
| `IsAppInstalled(source, appId)` | boolean, for the phone the player has open (`false` when none is open) |
| `InstallApp(deviceId, appId)` / `UninstallApp(deviceId, appId)` → `ok, errorCode` | Trusted install state changes |
| `SendAppMessage(source, appId, data)` → boolean | Push data into your app frame (owner resource only, ≤ 8 KB; the phone must be open with the app installed) |
| `OpenDeepLink(source, uri)` / `BuildDeepLink(app, path, query)` | Deep links |
| `SendNotification(source, n)` | Accepts `channel` and `data.deepLink` |
| `RegisterWidget` / `UpdateWidget` / `UnregisterWidget` | Widgets |

To plug in banking, economy, garage, housing, business, City, Services, Mail
actions or dispatch, see [Exports & integrations](#/docs/wd_phone/exports).

## Events

Server events (`AddEventHandler`), metadata only:

| Event | Arguments |
| --- | --- |
| `wd_phone:platform:appInstalled` | `deviceId, appId, version` |
| `wd_phone:platform:appUninstalled` | `deviceId, appId` |
| `wd_phone:platform:appUpdated` | `deviceId, appId, fromVersion, toVersion` |
| `wd_phone:platform:permissionChanged` | `deviceId, appId, permission, state` |
| `wd_phone:server:deviceLeftPlayer` | `source, deviceId`: the phone stopped being that player's (dropped, stashed, handed over, put on a charging pad, lost mode, switched off, logout or disconnect). Can fire more than once: keep handlers safe to repeat. |

## Browser add-on: in-game sites

The Browser (`nova_browser`) is a separate resource that registers with the
phone as an SDK app; players download it from the Nova Store. Start it after
`wd_phone`. It shows in-game sites that resources serve under made-up domains,
and only the real HTTPS websites you list in its `config.lua`
(`allowedOrigins`, empty by default, because a real website sees the
player's IP address). Its README, inside the zip, covers configuration.

Any resource can add an in-game site from its **server** script:

```lua
exports.nova_browser:RegisterSite('lifeinvader.ls', {
    title = 'Lifeinvader',
    url = 'html/index.html',   -- an .html file inside THIS resource
    icon = 'html/icon.png',    -- optional image inside THIS resource
    color = '#e5484d',         -- optional tile colour
}) --> ok, reason
exports.nova_browser:UnregisterSite('lifeinvader.ls')
exports.nova_browser:GetSites()  --> { ['lifeinvader.ls'] = { title, resource } }
```

- Register again whenever `nova_browser` starts (listen for `onResourceStart`),
  as with `RegisterApp`. A resource's sites are removed when it stops.
- Domains are lowercase letters, digits and dashes in dot-separated labels,
  4-64 characters (`www.` is dropped). The first resource to register a domain
  owns it. `allowedResources` in the Browser's config can limit who may
  register, and `maxSites` (default 100) caps the total.
- List the page and its icon in your `fxmanifest.lua` `files { ... }`.
- The page runs with its own resource's origin, so it can call its own NUI
  callbacks like an SDK app page. The path after the domain arrives as the
  URL hash: `lifeinvader.ls/u/42` loads `html/index.html#/u/42`; listen for
  `hashchange`.
- A site page talks to the Browser with `window.parent.postMessage(msg, '*')`:

| Message | Effect |
| --- | --- |
| `{ novaBrowser: 1, type: 'title', title }` | Sets the tab and history title |
| `{ novaBrowser: 1, type: 'navigate', path: '/u/43' }` | Goes to another path of this site (adds a Back entry and counts as a page load) |
| `{ novaBrowser: 1, type: 'open', address: 'other.ls/news' }` | Opens another address, with the same rules as the address bar |

The Browser accepts these only from the current tab's frame, and only when
the sender's origin is that site's resource. Each page load uses the phone's
mobile data (`useData('page')`).

## Security model

- The NUI accepts SDK messages only when `event.source` is your iframe's window
  **and** `event.origin` equals `https://cfx-nui-<your_resource>`.
- The iframe is sandboxed and cannot navigate the phone or read its storage.
- Everything that touches server data (notifications, contacts, mobile data,
  app storage) is re-checked on the server: device possession, install state,
  permission, rate limit. App storage is keyed by the server's device ID and
  the calling app ID, so an app only ever sees its own keys on that phone.
- Never trust data coming back from your own UI either. Validate it in your
  client/server scripts exactly as NovaOS does.
- Your app gets **no** access to phone numbers, device ids, PINs, wallet data,
  messages or other apps' data unless you are granted the matching permission
  and the SDK method exists.

## Icons

The NUI bundles a curated icon set. Besides the built-in app icons, these are
available to external apps and widgets: `app-window`, `gamepad-2`, `music`,
`radio`, `newspaper`, `globe`, `shopping-cart`, `utensils`, `dumbbell`,
`graduation-cap`, `stethoscope`, `fuel`, `bike`, `ticket`, `coffee`,
`book-open`, `tv`, `car-front`, `badge-dollar-sign`, `chart-line`, `qr-code`,
`key`, `siren`, `store`, `link`, `external-link`, `car`, `truck`, `home`,
`heart`, `star`, `wrench`, `shield`, `briefcase`, `map-pin`, `package`.
Unknown names render as a circle.
