> ## Documentation Index
> Fetch the complete documentation index at: https://docs.roadshop.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Apps

> Put an app from your own resource onto the headset home screen

# Custom Apps

Write your own resource, register an app, and it appears on the headset's home
screen. Opening it loads your page into a window.

**RoadVR is not modified in the process**, so an update overwrites nothing of
yours.

<Card title="Working examples" icon="folder-code">
  Two complete, running app resources ship in `examples/`:
  **`roadvr_exampleapp`**, which reads the game through its own Lua, and
  **`roadvr_mediaapp`** — the Media app itself, which needs almost no Lua and
  does everything through the SDK instead.
</Card>

## Why a page and not a component

RoadVR's interface is one compiled Vue bundle. Your resource cannot add a
component to a build that shipped before your resource existed — so your app is
loaded as a page in a frame instead.

That is the better deal anyway. Inside the frame you use whatever you like,
plain HTML or a framework, and a crash in your app cannot take the headset down
with it.

## The three exports

<CodeGroup>
  ```lua Register theme={null}
  exports.roadvr:registerRoadVrApp({
      id     = 'myres_shop',          -- unique, prefix it with your resource name
      page   = 'ui/index.html',       -- a file in YOUR resource
      name   = 'Shop',                -- shown under the icon
      icon   = 'ui/icon.png',         -- a file in YOUR resource
      size   = { w = 1.1, h = 0.7 },  -- metres, not pixels
  })
  ```

  ```lua Unregister theme={null}
  exports.roadvr:unregisterRoadVrApp('myres_shop')
  ```

  ```lua Send theme={null}
  exports.roadvr:sendToRoadVrApp('myres_shop', { type = 'tick', speed = 42 })
  ```
</CodeGroup>

### registerRoadVrApp

<ParamField path="id" type="string" required>
  Unique across the whole server. Prefix it with your resource name.
</ParamField>

<ParamField path="page" type="string" required>
  A file **in your resource**, e.g. `ui/index.html`. The full address —
  `https://cfx-nui-<your-resource>/<file>` — is built for you, so it cannot
  point somewhere else by accident. Put the file in your own `files {}` block.
</ParamField>

<ParamField path="name" type="string">
  Shown under the icon. Defaults to the id.
</ParamField>

<ParamField path="icon" type="string | table">
  A file in your resource, e.g. `ui/icon.png` — or a pair of tones, see [Two
  icons](#two-icons-if-you-want-the-setting-to-work) below.
</ParamField>

<ParamField path="size" type="table" default="{ w = 1.2, h = 0.75 }">
  Window size in **metres**. Not pixels — see [Writing the
  page](#writing-the-page).
</ParamField>

<ParamField path="hidden" type="boolean" default="false">
  Keep it off the home screen and open it yourself with
  `openRoadVrPanel(id)`.
</ParamField>

<ResponseField name="ok" type="boolean">
  `false` when the id already belongs to another resource — nobody gets to
  replace someone else's window.
</ResponseField>

<ResponseField name="err" type="string">
  Why it failed, when it did.
</ResponseField>

### Two icons, if you want the setting to work

`icon` may be a pair instead of one file:

```lua theme={null}
icon = { dark = 'ui/icon-dark.svg', light = 'ui/icon-light.svg' },
```

Settings has an **icon style** — dark, light, or following the interface theme
— and every built-in app ships both tones for it. One file name is fine and is
what most apps want; it simply shows under either setting. A pair makes your
icon follow the player's choice the same way the rest of the home screen does.

The recipe the built-in set uses: **dark** is a near-black tile with the glyph
in the app's own colour, **light** is a tile carrying that colour as a gradient
with a white glyph.

<Warning>
  Name **both** files in your `files {}` block. A file RoadVR asks for that is
  not listed is a missing icon and nothing that says so.
</Warning>

<Info>
  Give only one side of the pair and the other falls back to it, so half a pair
  is still an icon under both settings rather than a gap under one.
</Info>

### sendToRoadVrApp

<ResponseField name="return" type="boolean">
  `false` while the window is closed. **That is normal, not an error** — your
  resource has no idea whether the player has your app open.
</ResponseField>

## Building the resource

<Steps>
  <Step title="Declare your files">
    ```lua fxmanifest.lua theme={null}
    fx_version 'cerulean'
    game 'gta5'

    -- No ui_page. The page is rendered by RoadVR inside a headset window, so
    -- this resource never shows a NUI of its own — it only serves the files.
    files {
        'ui/index.html',
        'ui/icon.png',
    }

    client_scripts { 'client.lua' }

    dependencies { 'roadvr' }
    ```
  </Step>

  <Step title="Register on start">
    ```lua client.lua theme={null}
    CreateThread(function()
        -- A moment for RoadVR to have built its own tables. The manifest
        -- dependency guarantees the order of starting, not of the first frame.
        Wait(500)

        local ok, err = exports.roadvr:registerRoadVrApp({
            id   = 'myres_shop',
            name = 'Shop',
            page = 'ui/index.html',
            icon = 'ui/icon.png',
            size = { w = 1.1, h = 0.7 },
        })

        if not ok then
            print(('^1[myres_shop]^7 registration failed: %s'):format(err or 'unknown'))
        end
    end)
    ```
  </Step>

  <Step title="Start it">
    ```cfg server.cfg theme={null}
    ensure myres_shop
    ```

    Put the headset on and your icon is on the home screen.
  </Step>
</Steps>

## Talking to your own Lua

Nothing is needed from RoadVR for this. FiveM routes
`https://<your-resource>/<callback>` by resource name, no matter which page
fires the request, so inside the frame a plain fetch reaches your own
`RegisterNUICallback`:

<CodeGroup>
  ```js Page theme={null}
  const res = await fetch('https://myres_shop/getPlayerInfo', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json; charset=UTF-8' },
    body: JSON.stringify({}),
  })
  ```

  ```lua client.lua theme={null}
  RegisterNUICallback('getPlayerInfo', function(_, cb)
      cb({ health = GetEntityHealth(PlayerPedId()) - 100 })
  end)
  ```
</CodeGroup>

The one direction you cannot do alone is inbound. Your own `SendNUIMessage`
would go to your own frame, and that one is never rendered. Use
`sendToRoadVrApp` instead.

## Talking to RoadVR itself

Your own Lua tells you about your world. The **SDK** tells you about the
headset: which theme is on, which language the player picked, how big your
window is, whether it is focused — and, the one that matters most, that it is
about to close.

```html theme={null}
<script src="https://cfx-nui-roadvr/public/sdk/roadvr.js"></script>
```

The path is relative to the RoadVR resource, so `public/` belongs in it. There
is nothing to add to your own `files {}` block — the file is served by RoadVR,
so there is no copy in your resource that can go stale.

```js theme={null}
;(async function () {
  // Opened straight in a browser the cfx-nui- address does not resolve at all.
  if (!window.RoadVr) return

  const ctx = await RoadVr.ready()

  RoadVr.applyTheme()          // your page now uses RoadVR's colours

  RoadVr.on('theme', (t) => console.log(t.light ? 'light' : 'dark'))
  RoadVr.on('resize', ({ width, height }) => layout(width, height))
  RoadVr.on('close', () => save())
})()
```

<Card title="Full SDK reference" icon="code" href="/roadvr/api/sdk">
  Every field, every event, every function
</Card>

### The events, briefly

| Event                | When                                                              |
| -------------------- | ----------------------------------------------------------------- |
| `theme`              | light/dark toggled, accent or icon set changed                    |
| `locale`             | the player switched interface language                            |
| `world`              | weather and clock tick — only after `watch('world')`              |
| `placement`          | how far your window stands away — only after `watch('placement')` |
| `focus` / `blur`     | your window became, or stopped being, the focused one             |
| `visible` / `hidden` | pause menu, map, cinema screen, fast head turn                    |
| `resize`             | your window was dragged to a new size                             |
| `anchor`             | your window switched between following the player and staying put |
| `action`             | one of your ornament buttons was pressed                          |
| `close`              | your window is going away                                         |
| `message`            | a push from your own Lua, i.e. `sendToRoadVrApp`                  |

Every `on()` returns its counterpart:

```js theme={null}
const off = RoadVr.on('theme', paint)
off()
```

### Two things worth knowing about

**`watch('placement')`** tells you how far your window is standing from the
player's head, in metres, four times a second and only while it changes. It is
what the Media app turns its volume down with as somebody walks away.

```js theme={null}
RoadVr.watch('placement')
RoadVr.on('placement', ({ distance }) => fade(distance))
```

**`setActions()`** hangs up to three buttons in your window's ornament, left of
the close button — the place for a control that has to stay reachable while
your page cannot offer one, over a video or a map.

```js theme={null}
RoadVr.setActions([
  { id: 'back', label: 'Back', icon: 'solar:alt-arrow-left-bold', onSelect: showList },
])
```

<Note>
  The SDK is optional. A page that only wants pushes from its own Lua can keep
  using a raw listener — everything RoadVR forwards carries `source: 'roadvr'`:

  ```js theme={null}
  window.addEventListener('message', (e) => {
    if (e.data?.source !== 'roadvr') return
    console.log(e.data.type, e.data.speed)
  })
  ```
</Note>

## Writing the page

The window renders at a fixed **1280×800 CSS pixels** whatever its size in
metres. Sizes look oversized in a browser on purpose; that is the same scale
every built-in app uses.

<AccordionGroup>
  <Accordion title="Keep the background transparent">
    RoadVR's glass sits behind your page. A background colour on `body` shows
    up as a rectangle inside the rounded window.

    ```css theme={null}
    html, body { background: transparent; }
    ```
  </Accordion>

  <Accordion title="Hide the cursor">
    The headset draws its own pointer, and the system cursor would appear next
    to it.

    ```css theme={null}
    * { cursor: none; }
    ```
  </Accordion>

  <Accordion title="Do not scroll">
    Set `overflow: hidden` and make your page fit its window. A scrollbar down
    the side of a floating pane looks like a fault, and the wheel is taken
    anyway — it moves the window closer and further away.

    ```css theme={null}
    html, body { overflow: hidden; height: 100%; margin: 0; }
    ```
  </Accordion>

  <Accordion title="Make sure nothing can be selected">
    RoadVR sets `user-select: none` on its own panels, but that rule **stops at
    the frame boundary**. Your page is its own document and starts from the
    default, which is selectable — so dragging across your window paints it
    blue while the headset draws its own pointer next to the smear.

    RoadVR cannot do this for you. Set it yourself:

    ```css theme={null}
    html, body { user-select: none; -webkit-user-select: none; }

    /* The one exception, and the same one RoadVR makes for itself: a field you
       type into is unusable without a caret and a selection. */
    input, textarea { user-select: text; -webkit-user-select: text; }
    ```
  </Accordion>

  <Accordion title="Take RoadVR's colours">
    Point your own variables at RoadVR's, with a fallback so the page still
    opens in a browser:

    ```css theme={null}
    :root {
      --text: var(--vr-text, rgba(255, 255, 255, 0.96));
      --dim: var(--vr-text-dim, rgba(255, 255, 255, 0.55));
      --fill: var(--fill-idle, rgba(255, 255, 255, 0.13));
      --accent: var(--vr-accent, #0a84ff);
    }
    ```

    Then call `RoadVr.applyTheme()` once and the page follows the player
    switching theme, without knowing a single colour value.
  </Accordion>
</AccordionGroup>

## Lifecycle

Register once at resource start. **There is no need to unregister on stop** —
RoadVR notices your resource going away, removes the app and closes its window.

Registration survives the headset being taken off and put back on. The list is
kept on the Lua side and pushed to the interface again on every boot, so a
resource that started long before anyone put a headset on still shows up.

## Trying the examples

```
ensure roadvr_exampleapp
```

Put the headset on and the **Example** icon is on the home screen. It shows
your street, health and armour on request, receives a speed reading pushed once
a second, honks the horn of the vehicle you are in — and, in its second column,
reports everything the headset tells it through the SDK.

```
ensure roadvr_mediaapp
```

The **Media** app, as an ordinary resource of its own. It is the other shape:
almost no Lua at all — one `registerRoadVrApp` call and nothing else — with
every runtime detail coming through the SDK instead. It is also the one to read
for `watch('placement')` and `setActions()`, both of which it uses for real.

<Info>
  `roadvr_mediaapp` registers under the id `media`, which is the id the built-in
  Media app used. Starting it takes that place over rather than adding a second
  icon, so `openRoadVrPanel('media')` keeps working either way.
</Info>
