> For the complete documentation index, see [llms.txt](https://docs.sourcedev.pro/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.sourcedev.pro/src-phone/custom-apps.md).

# Custom Applications

External applications run from a separate FiveM resource and register with `src-phone` at runtime. The phone does not compile third-party code into its own React bundle.

## Design boundary

The phone shell remains responsible for:

* Status bar and Dynamic Island.
* Shared application header and back button.
* Home gesture and application switcher.
* Lock state and phone visibility.
* Notifications and network availability checks.
* Application viewport clipping and pointer-event control.

The external resource owns only the content rendered below the shared header. It runs in a sandboxed iframe on its own `cfx-nui` origin. This prevents application CSS and DOM code from modifying the phone shell.

## Starting from the boilerplate

Copy `src-phone/boilerplate/src-phone-app` to a top-level resource directory and rename the folder:

```
resources/
  src-phone-app/
    fxmanifest.lua
    config.lua
    client.lua
    web/
      blank.html
      package.json
      tsconfig.json
      vite.config.ts
      index.html
      public/
      src/
      dist/
```

Keep `fxmanifest.lua`, `config.lua`, and `client.lua` at the resource root. TypeScript, npm, Vite, source assets, and the production bundle belong under `web/`.

Install and build from the web directory:

```powershell
cd resources/src-phone-app/web
npm install
npm run typecheck
npm run build
```

Start order:

```cfg
ensure src-phone
ensure src-phone-app
```

## Why `blank.html` is the resource UI page

FiveM mounts every resource `ui_page` as a game-wide NUI surface. If the application uses `web/dist/index.html` directly as `ui_page`, its interface appears across the game screen as soon as the resource starts.

Use a transparent page for the automatic surface:

```lua
ui_page 'web/blank.html'

files {
    'web/blank.html',
    'web/dist/index.html',
    'web/dist/**/*'
}

client_scripts {
    'config.lua',
    'client.lua'
}
```

`src-phone` loads the real `uiPage` inside the phone and appends `srcPhoneEmbedded=1`. Keep the entry-page guard supplied by the boilerplate as a second protection:

```html
<style>
  html:not(.src-phone-embedded) {
    display: none !important;
    background: transparent !important;
  }
</style>
<script>
  if (new URLSearchParams(window.location.search).get('srcPhoneEmbedded') === '1') {
    document.documentElement.classList.add('src-phone-embedded');
  }
</script>
```

Do not replace this with `window.self !== window.top`. FiveM's normal NUI surface already runs inside an iframe, so that check cannot distinguish the phone viewport.

## Application config

The root `config.lua` file is the single source for application metadata. The shell validates this table during registration and sends the accepted values to the TypeScript SDK when the application opens:

```lua
PhoneAppConfig = {
    apiVersion = 1,
    id = 'my-first-app',
    name = 'My App',
    uiPage = 'web/dist/index.html',
    icon = 'web/dist/app-icon.svg',
    iconClass = 'fas fa-cube',
    accentColor = '#5B8CFF',
    permissions = { 'storage', 'notify' },
    requiresInternet = false,
    defaultInstalled = true,
}
```

Do not duplicate these values in a TypeScript manifest. In particular, permission checks and storage namespacing use the validated runtime config sent by `src-phone`.

### Config fields

| Field              | Required | Rules                                                                                           |
| ------------------ | -------- | ----------------------------------------------------------------------------------------------- |
| `apiVersion`       | Yes      | Must be `1`. Unknown major versions are rejected.                                               |
| `id`               | Yes      | Unique kebab-case ID, 2-48 characters, beginning with a lowercase letter.                       |
| `name`             | Yes      | Display name, 1-18 Unicode characters.                                                          |
| `uiPage`           | No       | Relative resource path. Defaults to `web/dist/index.html`. External URLs and `..` are rejected. |
| `icon`             | No       | Relative SVG or PNG path. At least 128 by 128 is recommended.                                   |
| `iconClass`        | No       | Font Awesome fallback class. Defaults to `fas fa-cube`.                                         |
| `accentColor`      | No       | Six-digit hex color. Defaults to `#5B8CFF`.                                                     |
| `permissions`      | No       | v1 allowlist: `storage`, `notify`.                                                              |
| `requiresInternet` | No       | When `true`, the phone blocks launch while offline. Defaults to `false`.                        |
| `defaultInstalled` | No       | Defaults to `true`. See installation modes below.                                               |

Built-in application IDs are reserved. Do not use IDs such as `phone`, `messages`, `settings`, `camera`, `appstore`, or `garage`.

## Installation modes

### Resource-provided application

```lua
defaultInstalled = true,
```

The application is installed automatically while its resource is registered. It behaves like a resource-provided system application and cannot be uninstalled from edit mode.

### App Store application

```lua
defaultInstalled = false,
```

The application appears in the phone App Store. Each player installs or removes it through the existing persistent installed-app and home-layout flow.

Stopping the owner resource removes its registration. Starting it again makes it available under the same ID.

## Registration

Load `config.lua` before `client.lua` in `fxmanifest.lua`, then pass `PhoneAppConfig` to the public export:

```lua
local function registerPhoneApp()
    if type(PhoneAppConfig) ~= 'table' then
        print('PhoneAppConfig is missing or invalid')
        return
    end

    local called, registered, reason = pcall(function()
        return exports['src-phone']:registerApp(PhoneAppConfig)
    end)

    if not called or not registered then
        print(('src-phone registration failed: %s'):format(reason or registered or 'unknown'))
    end
end

CreateThread(function()
    Wait(0)
    registerPhoneApp()
end)

AddEventHandler('onResourceStart', function(resource)
    if resource == 'src-phone' then
        registerPhoneApp()
    end
end)
```

The second registration path is required because restarting `src-phone` clears its in-memory registry while the external resource remains running.

## TypeScript contract

The boilerplate SDK exposes the application context:

```ts
export interface PhoneAppContext {
  navigate(route: string, params?: Record<string, unknown>): void;
  goBack(): void;
  close(): void;
  notify(message: string, tone?: 'neutral' | 'success' | 'error'): void;
  storage: {
    get<T>(key: string): T | null;
    set<T>(key: string, value: T): void;
    remove(key: string): void;
  };
  setTitle(title: string): void;
}
```

Initialize one SDK instance:

```ts
import { createPhoneApp } from './sdk';

const phone = createPhoneApp();

phone.onReady((app) => {
  console.log(`${app.name} initialized for ${app.resource}`);
});
```

`onReady` receives the validated `id`, `name`, owner `resource`, `accentColor`, and `permissions` from the shell. Do not create one SDK instance per screen. A single instance owns the runtime application metadata, route stack, shell messaging, launch parameters, and lifecycle subscriptions.

## Navigation and header

Use SDK navigation instead of creating a second top-level header:

```ts
phone.context.navigate('/details', { recordId: 42 });

phone.onRoute(({ route, params }) => {
  renderRoute(route, params);
});
```

The shell back button sends a back request to the SDK. `goBack()` pops the application route stack. At the root route it closes the application and returns to the phone home screen.

Set the shared header title when a route changes:

```ts
phone.context.setTitle('Details');
```

The shell truncates title input to 32 characters. Do not render a duplicate fixed header inside the content viewport.

## Launch parameters

Read parameters passed when the shell opens the application:

```ts
const params = phone.getLaunchParams();
```

Treat all launch parameters as untrusted input. Validate types and identifiers before using them in a resource callback.

## Lifecycle

Applications remain mounted for switching and recent-app behavior. Pause work when inactive:

```ts
phone.onLifecycle((active) => {
  if (active) {
    startPolling();
  } else {
    stopPolling();
  }
});
```

Stop polling, timers, expensive animation, camera access, and continuous audio when `active` is `false`. Background applications do not receive pointer events.

## Runtime events from Lua

Client or server integrations can deliver data to a mounted external app with the public `sendAppEvent` export. The shell checks the target app ID and forwards the event into that app's iframe; the external app does not listen to raw FiveM window messages.

Client:

```lua
exports['src-phone']:sendAppEvent('my-first-app', 'order:updated', {
    id = 42,
    status = 'ready'
})
```

Server:

```lua
exports['src-phone']:sendAppEvent(source, 'my-first-app', 'order:updated', {
    id = 42,
    status = 'ready'
})
```

TypeScript:

```ts
phone.onEvent((action, payload) => {
  if (action !== 'order:updated') return;
  const update = payload as { id?: number; status?: string };
  if (typeof update.id !== 'number') return;
  updateOrder(update);
});
```

Validate every payload in TypeScript. Server-authored events still become browser input once they enter the iframe.

## Calling the application's resource

The SDK targets the external application's own NUI callbacks, not `src-phone`:

```ts
const response = await phone.nui<{ ok: boolean; name?: string }>(
  'example:getPlayerName',
  {},
);
```

Register the callback in the external resource:

```lua
RegisterNUICallback('example:getPlayerName', function(_, cb)
    cb({
        ok = true,
        name = GetPlayerName(PlayerId()),
    })
end)
```

Every callback path must call `cb(...)` exactly once. Validate server-owned actions on the server. A NUI payload must never be trusted for money, inventory, permissions, jobs, or ownership.

## Storage permission

Declare `storage` before using the SDK storage wrapper:

```ts
type Draft = { text: string; updatedAt: number };

phone.context.storage.set<Draft>('draft', {
  text: 'Example',
  updatedAt: Date.now(),
});

const draft = phone.context.storage.get<Draft>('draft');
phone.context.storage.remove('draft');
```

Keys are namespaced by application ID and limited to safe characters. Storage is suitable for UI preferences and drafts. Do not store authoritative gameplay state, credentials, or secrets in browser storage.

## Notification permission

Declare `notify` before sending a shell notification:

```ts
phone.context.notify('Record saved', 'success');
```

The phone limits the message length and routes it through the shared notification banner and notification center. External applications do not create global fixed-position toasts.

## Theme and layout tokens

The SDK writes shell theme values to CSS custom properties:

```css
var(--phone-bg)
var(--phone-card)
var(--phone-card-active)
var(--phone-separator)
var(--phone-text)
var(--phone-text-secondary)
var(--phone-accent)
var(--phone-destructive)
var(--phone-safe-left)
var(--phone-safe-right)
var(--phone-safe-bottom)
```

Recommended baseline:

* 16 px horizontal content padding.
* 8, 12, 16, 24, and 32 px spacing scale.
* 12 px card radius.
* At least 52 px list-row height.
* At least 44 by 44 px interaction targets.
* 20/24 px page headings, 14/20 px body text, and 12/16 px metadata.

Use theme tokens rather than fixed light or dark colors.

## UI restrictions

External applications must not:

* Access or modify the parent document.
* Render a replacement Dynamic Island, status bar, home indicator, or phone header.
* Use `position: fixed` for application-wide overlays.
* Depend on `100vw` or `100vh` for phone sizing.
* Use arbitrary global z-index values to escape the application viewport.
* Navigate the top frame, open popup windows, or target external form windows.
* Call another resource's private NUI endpoint.
* Request permissions that the application does not use.

The iframe sandbox is a boundary, not a replacement for server-side authorization.

## Custom application release checklist

1. Use a unique ID and keep it unchanged after release.
2. Confirm `web/blank.html` remains the `ui_page`.
3. Run `npm run typecheck` and `npm run build` from `web/`.
4. Confirm `web/dist/index.html` and the icon are included in `files`.
5. Start the app before and after restarting `src-phone`.
6. Test install and uninstall behavior for the selected `defaultInstalled` mode.
7. Test root and nested-route back behavior.
8. Test light and dark themes.
9. Test offline launch when `requiresInternet` is enabled.
10. Test inactive lifecycle behavior in the application switcher.
11. Test empty, loading, error, and long-content states.
12. Confirm no application UI appears outside the phone when the resource starts.

See [API and Exports](/src-phone/api-exports.md) for registration errors and return values.
