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

# Custom App

GKS Tablet lets you add **custom iframe apps** at runtime. Create a separate resource with a NUI page, register it with `AddCustomApp`, and it appears in the App Store / home screen like a built-in app.

The API matches [GKSPhone Custom App](https://docs.gkshop.org/gksphone-v2/custom-app). Use the starter template: [gksphone-app on GitHub](https://github.com/Xenknight61/gksphone-app).

***

### Custom apps using UI

Create a separate resource and set `appurl` to your HTML file (`https://cfx-nui-{resource}/{path}`).

When the app opens, the tablet loads your page in an iframe and injects `window.gkstablet` (and `window.gksphone` as an alias for shared phone/tablet UIs).

***

### Adding the app

Use the AddCustomApp export from your client script:

```lua
exports['gks-tablet']:AddCustomApp({
    name = "FleetTracker", -- unique app name
    appurl = "https://cfx-nui-my-tablet-app/ui/index.html", -- your NUI URL
    icons = "https://cfx-nui-my-tablet-app/ui/icon.png", -- app icon
    description = "Track department vehicles", -- App Store description
    show = true, -- show in App Store
    startapp = false, -- auto-install on home screen
    allowjob = { "police", "lspd" }, -- job whitelist (optional)
    blockedjobs = {}, -- job blacklist (optional)
    labelLangs = { -- localized name (optional)
        en = "Fleet Tracker",
        tr = "Filo Takip",
    },
    onOpen = function(tabletUniqueId) -- runs when app opens (optional)
        print("Opened on tablet:", tabletUniqueId)
    end,
    onClose = function(tabletUniqueId) -- runs when app closes (optional)
        print("Closed")
    end,
})
```

Call `AddCustomApp` again after a resource restart — install state is saved in the database, but metadata (URL, icon, jobs) comes from your export each session.

When the iframe loads, the tablet POSTs to `https://{your-resource}/getInfo`. Handle it to sync data on open:

```lua
RegisterNUICallback('getInfo', function(data, cb)
    cb('ok')
end)
```

You can also register lifecycle callbacks by resource name:

```lua
exports['gks-tablet']:onAppOpen('my-tablet-app', function(tabletUniqueId) end)
exports['gks-tablet']:onAppClose('my-tablet-app', function(tabletUniqueId) end)
exports['gks-tablet']:removeAppCallbacks('my-tablet-app')
```

***

### InputChange

Prevent the player from walking while typing in iframe inputs.

```lua
exports['gks-tablet']:InputChange(true)  -- allow movement
exports['gks-tablet']:InputChange(false) -- block movement
```

Or use the bridge in HTML:

```html
<input onfocus="window.gkstablet?.inputFocused(false)" onblur="window.gkstablet?.inputFocused(true)">
```

***

### Sending a message to the UI

Use NuiSendMessage instead of `SendNUIMessage`:

```lua
exports['gks-tablet']:NuiSendMessage({ event = 'update', value = 42 })
```

Listen in your frontend the same way as a normal NUI message:

```javascript
window.addEventListener('message', (event) => {
    if (event.data?.event === 'update') {
        console.log(event.data.value)
    }
})
```

***

### Imported functions

When the iframe loads, the tablet injects a bridge into `window.gkstablet`. `window.gksphone` points to the same object (for shared phone/tablet code).

| Name                       | Type     | Description                         |
| -------------------------- | -------- | ----------------------------------- |
| `url`                      | string   | Your resource name (from `appurl`)  |
| `isDarkMode()`             | function | Returns tablet dark mode state      |
| `onChangeDarkMode(cb)`     | function | Called when theme changes           |
| `loadingPopup(text)`       | function | Show loading dialog                 |
| `closeLoadingPopup()`      | function | Close loading dialog                |
| `notify(text, timeout)`    | function | Show toast                          |
| `fetchNui(event, data)`    | function | Send NUI callback                   |
| `inputFocused(allowWalk)`  | function | Block/unblock movement while typing |
| `GetGallery(...)`          | function | Open gallery picker                 |
| `FullScreenImage(url)`     | function | Open image viewer                   |
| `SelectEmoji(open)`        | function | Open emoji picker                   |
| `setStatusBarColor(color)` | function | Set page background color           |
| `calling(number, anon)`    | function | Not supported on tablet (no-op)     |
| `videoCall(number)`        | function | Not supported on tablet (no-op)     |
| `CameraOpen(...)`          | function | Returns `null` on tablet            |

#### fetchNui

```javascript
window.gkstablet.fetchNui('my-event', { foo: 'bar' })
```

#### isDarkMode

```javascript
const dark = window.gkstablet.isDarkMode()
```

#### notify

```javascript
window.gkstablet.notify('Saved!', 2000)
```

#### GetGallery

```javascript
const result = await window.gkstablet.GetGallery(false, true, false, false)
// { data: 'url', opencamera: false } or null
```

#### FullScreenImage

```javascript
window.gkstablet.FullScreenImage('https://example.com/photo.jpg')
```

#### SelectEmoji

```javascript
window.gkstablet.SelectEmoji(true)

window.addEventListener('message', (event) => {
    if (event.data?.type === 'emojiSelected') {
        console.log(event.data.eventData)
    }
})
```

#### setStatusBarColor

```javascript
window.gkstablet.setStatusBarColor('#000000')
```

***
