@xphone/sdk
v0.6.0
Published
Standalone TypeScript SDK for building xphone (FiveM) phone plugins — typed runtime, capabilities (audio, Dynamic Island, notifications, Siri, device), iOS UI components, i18n and validation.
Maintainers
Readme
@xphone/sdk
Standalone TypeScript SDK for building xphone (FiveM) phone plugins.
Build a phone app in your own repository, ship it as a normal FiveM resource, and have it appear and run inside xphone — without a copy of the xphone monorepo. Everything you need is in this package: a typed plugin client, capabilities (audio, Dynamic Island, notifications, Siri, device state), an iOS-style Vue component library, i18n and form validation.
- 🧩 Drop-in — your app is a standalone resource; the host discovers it at runtime.
- 🔒 Self-contained — no
xphone-coresource required to build. - 🟦 Typed — written in TypeScript; full
.d.tsfor every entry point. - 🎛️ Full control — close/open, toasts, notifications, Dynamic Island, audio & volume, Siri voice intents, and device/theme state.
Install
npm install @xphone/sdk
# peer deps you use:
npm install vue # if you build a Vue UI (most plugins do)
npm install zod # if you use @xphone/sdk/validation
npm install -D vite @vitejs/plugin-vueThe fastest way to start is the scaffolder:
npm create @xphone/plugin@latest my-appvue, pinia and zod are optional peer dependencies — install only what
you import. lucide-vue-next (used by the component library) is a regular
dependency and is installed for you.
Quick start
// vite.config.ts
import vue from '@vitejs/plugin-vue'
import { createStandalonePluginConfig } from '@xphone/sdk/vite'
export default createStandalonePluginConfig({ plugins: [vue()] })<!-- src/App.vue -->
<script setup lang="ts">
import { useXphonePlugin } from '@xphone/sdk/vue'
const phone = useXphonePlugin('my-app', { language: 'en' })
function start() {
phone.toast('Job started', 'success')
phone.dynamicIsland.set('job', { layout: 'progress', title: 'Working', progress: 0 })
}
</script>
<template>
<button @click="start">Start</button>
</template>// src/main.ts
import { createApp } from 'vue'
import '@xphone/sdk/styles'
import App from './App.vue'
createApp(App).mount('#app')npm run build produces a self-contained ui/dist/ you point your resource's
iframe at (see Hosting in xphone).
The plugin client
useXphonePlugin(appId, options?) (Vue) and createXphoneClient(options)
(framework-agnostic) return the same client. Capabilities are namespaced; the
most common actions also have top-level shortcuts.
const phone = useXphonePlugin('my-app')
// shortcuts
phone.close()
phone.openApp('settings')
phone.toast('Saved', 'success')
phone.notify({ title: 'New message', body: 'Tap to open' })
// capabilities (see below)
phone.dynamicIsland.set(/* … */)
phone.audio.setVolume(0.5)
phone.siri.register(/* … */)
const state = await phone.device.getState()
// talk to your own resource's NUI callbacks
const data = await phone.fetch('getProfile', { id: 1 })Capabilities
| Namespace | What it does |
| --- | --- |
| phone.app | close(), open(target?), lifecycle: onOpened/onClosed/onForeground/onBackground |
| phone.feedback | toast(message, type) |
| phone.notifications | notify({ title, body, groupKey, openAction }) |
| phone.dynamicIsland | set/update/end/endAll live activities, onEvent for taps/actions |
| phone.audio | setVolume/getVolume, play/stop, duck/unduck, onVolumeChange |
| phone.bluetooth | getState(), scan(), connect(id), disconnect(), routeAudio/stopAudio, onChange — play "to Bluetooth" like a real phone (requires host ≥ 0.5) |
| phone.siri | register(intents), unregister, onInvoke (return a string to speak back) |
| phone.device | getState(), onThemeChange, onLanguageChange |
| phone.identity | get() → current player number, balance, avatar, socials |
| phone.contacts | list(), find(query) |
| phone.calls | dial(target, { video }) (number or contact name) |
| phone.camera / phone.gallery | capture() / pick() → result via phone.onPhoto(...) |
| phone.location | getPosition(), setWaypoint(x, y) |
| phone.maps | open({ x, y, label }) |
| phone.notifications.setBadge(n) | set the home-screen icon badge |
| phone.app.open(target, payload) / onOpenPayload(...) | deep-link between apps |
const me = await phone.identity.get() // { number, balance, avatar, … }
const contacts = await phone.contacts.find('jo')
phone.calls.dial('Joana')
phone.onPhoto(({ url, source }) => setAvatar(url))
phone.camera.capture() // opens camera; result via onPhoto
const pos = await phone.location.getPosition()
phone.maps.open({ x: pos.x, y: pos.y, label: 'Me' })
phone.notifications.setBadge(3)Camera/gallery navigate away and reload your iframe, so the result is delivered to
onPhotoafter your app reopens (the host buffers it — you won't miss it). The Dynamic Island auto-hides while your app is in the foreground and reappears when you leave it, like real iOS — no action needed.
Dynamic Island
phone.dynamicIsland.set('delivery', {
layout: 'progress',
title: 'Delivering',
subtitle: 'ETA 4 min',
progress: 0.25,
actions: [{ id: 'cancel', label: 'Cancel' }]
})
phone.dynamicIsland.onEvent(({ event, actionId }) => {
if (event === 'action' && actionId === 'cancel') phone.dynamicIsland.end('delivery')
})Audio & volume
phone.audio.setVolume(0.4) // 0..1
phone.audio.play({ src: 'nui://my-app/sfx/ping.ogg' })
phone.audio.duck() // lower host media while you speak
const vol = await phone.audio.getVolume()Siri voice intents
phone.siri.register([
{ name: 'play', phrases: ['play my playlist', 'start the music'] }
])
phone.siri.onInvoke(({ intent, args }) => {
if (intent === 'play') {
startPlayback()
return 'Playing your playlist' // spoken back to the user
}
})Device & lifecycle
const { theme, language } = await phone.device.getState()
phone.device.onThemeChange((t) => applyTheme(t))
phone.app.onForeground(() => refresh())Audio, Siri and device capabilities require an xphone host ≥ 0.4. On older hosts these calls are safely ignored.
Receiving data from your resource
Your server/client Lua can push data into the running iframe:
exports['xphone-core']:PushNuiMessage({ action = 'pluginPush', appId = 'my-app', payload = { online = 12 } })phone.onPush((payload) => {
console.log('players online:', payload.online)
})Server side (@xphone/sdk/server)
A CommonJS layer for your plugin's server script — require() it from your
server_script (FiveM server scripts run as CJS). It absorbs the manifest
lifecycle, schema apply, and a typed UI→server callback bus, plus typed access
to identity and banking.
// server/main.js — server_script 'server/main.js'
const { definePlugin, identity, banking } = require('@xphone/sdk/server')
definePlugin({
manifest: {
id: 'pizza',
label: 'Pizza LS',
uiType: 'iframe',
iframeUrl: `nui://${GetCurrentResourceName()}/ui/dist/index.html`
},
schema: true, // applies sql/schema.sql on start (no-op without oxmysql)
callbacks: {
// Reached from the UI with phone.server.call('order', payload).
// ctx already has { src, license, licenseKey } for the caller.
async order(ctx, payload) {
const ok = await banking.removeMoney(ctx.license, payload.price)
return { ok, number: await identity.getNumber(ctx.license) }
}
}
})// In the app (iframe):
const res = await phone.server.call('order', { price: 25 })
if (res.ok) phone.toast('Ordered!') // res.data holds your handler's return| API | What it does |
| --- | --- |
| definePlugin({ manifest, schema?, callbacks? }) | register the app (race-safe on both starts), apply schema, wire callbacks |
| identity | licenseFromSource, licenseKey (hex, path-safe), getNumber, resolveLicense, findSourceBy* |
| banking | getBalance, addMoney, removeMoney, transfer (atomic), createInvoice |
| db | query/insert/update (auto ${DBPrefix}), table(name), applySchema(file?) |
Callbacks may validate input — pass { input: z.object({…}), handler } instead
of a bare function (any .parse()-able schema works). Add @xphone/sdk to your
plugin's dependencies so the FiveM resource builder installs it server-side.
Entry points
| Import | Contents |
| --- | --- |
| @xphone/sdk | client, protocol, validation, i18n, design tokens (no Vue) |
| @xphone/sdk/server | server tier (CJS): definePlugin, identity, banking, db |
| @xphone/sdk/runtime | createXphoneClient, transport, protocol |
| @xphone/sdk/vue | useXphonePlugin composable |
| @xphone/sdk/components | iOS-style Vue component library |
| @xphone/sdk/validation | Zod-powered, i18n-aware forms (usePluginForm, v, z) |
| @xphone/sdk/i18n | locale loading & translation |
| @xphone/sdk/ui | design tokens (colors, typography, shapes, motion) |
| @xphone/sdk/styles | base stylesheet (CSS) |
| @xphone/sdk/vite | createStandalonePluginConfig |
| @xphone/sdk/dev | bootstrapPluginDev for local dev |
| @xphone/sdk/nui | low-level NUI helpers |
| @xphone/sdk/protocol | raw wire contract (constants + types) |
| @xphone/sdk/plugin | defineXphonePlugin, PluginDefinition (integrated-tier contract) |
Integrated vs standalone tiers
There are two ways to build an app, and they use different parts of this package:
- Standalone (third-party) — your app is its own resource, installed from npm. It registers on the server with
RegisterPhoneAppManifestand talks to the host throughcreateXphoneClient()(audio, maps, dynamic island, notifications, etc. viaclient.*). Everything documented above is available. Start withnpm create @xphone/plugin@latest. - Integrated (first-party) — the app lives in the xphone monorepo and is compiled by the host's NUI build. It exports a
nui/plugin.jstyped withdefineXphonePluginfrom@xphone/sdk/plugin, and may reach host internals directly through the integrated-only subpaths@xphone/sdk/phone,/audio,/maps,/social,/dynamic-island.
⚠️ The integrated-only subpaths (
/phone,/audio,/maps,/social,/dynamic-island) only resolve inside the monorepo (the host wires them via Vite aliases). A standalone plugin that imports one gets a clear error at import time — usecreateXphoneClient()instead. Likewise, the capability namespaces in the table above (identity,contacts,calls, …) are served by the host: their availability depends on the host version, so feature-detect rather than assume.
Components
import '@xphone/sdk/styles'
import { ValidatedField, IosToggle, IosButton, IosOverlayProvider } from '@xphone/sdk/components'Validation
import { usePluginForm, v, z } from '@xphone/sdk/validation'
const form = usePluginForm(
() => z.object({ name: v.requiredString(), age: v.age(18) }),
{ name: '', age: '' },
{ t: phone.t } // validation messages are i18n keys
)Hosting in xphone
A plugin is a normal FiveM resource. Register it with the host on startup — no
edits to xphone-core required:
-- server.lua
AddEventHandler('onResourceStart', function(res)
if res ~= GetCurrentResourceName() then return end
exports['xphone-core']:RegisterPhoneAppManifest({
id = 'my-app',
label = 'My App',
icon = ('nui://%s/ui/dist/icon.png'):format(GetCurrentResourceName()),
uiType = 'iframe',
iframeUrl = ('nui://%s/ui/dist/index.html'):format(GetCurrentResourceName()),
resource = GetCurrentResourceName()
})
end)The icon then appears on the home screen and your index.html is shown in an
iframe when the app is opened.
Home-screen widgets
Apps can own home-screen widgets — just like the built-in ones. A widget belongs to your app: it only appears in the widget gallery while your app is installed, and it vanishes if the app is removed. You can declare more than one per app.
Two tiers, matching the two ways to build a plugin:
Standalone (iframe widget)
Declare a widgets array in your server manifest. Each widget points at an HTML
page your resource serves; it renders inside a sized card and is display-only
(the phone owns taps and long-press — tapping opens your app, long-press enters
home customization). Sizes: 2x2 (square) or 4x2 (wide).
exports['xphone-core']:RegisterPhoneAppManifest({
id = 'my-app',
label = 'My App',
uiType = 'iframe',
iframeUrl = ('nui://%s/ui/dist/index.html'):format(GetCurrentResourceName()),
resource = GetCurrentResourceName(),
widgets = {
{
id = 'my-app:summary',
labelKey = 'widget_my_app_summary', -- i18n key (your plugin locales)
size = '2x2',
iframeUrl = ('nui://%s/ui/widget.html'):format(GetCurrentResourceName())
},
{
id = 'my-app:wide',
labelKey = 'widget_my_app_wide',
size = '4x2',
iframeUrl = ('nui://%s/ui/widget-wide.html'):format(GetCurrentResourceName())
}
}
})Expose the widget pages in your fxmanifest.lua (files { 'ui/*.html' }). The
typed manifest shape is exported as PhoneAppManifest / WidgetManifest from
@xphone/sdk/plugin. A complete example lives in phone-apps/xphone-example-sdk.
Integrated (native Vue widget)
A native plugin renders its widget with a Vue component. List widgets on the
plugin definition; the host registers each (the widget receives a def prop and
emits open).
import { defineXphonePlugin } from '@xphone/sdk/plugin'
import MyApp from './apps/MyApp.vue'
import SummaryWidget from './widgets/SummaryWidget.vue'
export default defineXphonePlugin({
id: 'my-app',
component: MyApp,
widgets: [
{ id: 'my-app:summary', labelKey: 'widget_my_app_summary', size: '2x2', component: SummaryWidget }
]
})opens (optional) sets which app a tap opens — it defaults to your app.
TypeScript
Everything is typed. The wire contract is exported from @xphone/sdk/protocol
if you need to type a server push or build a custom transport:
import type { DynamicIslandPayload, SiriIntent, DeviceState } from '@xphone/sdk/protocol'Building this package (contributors)
npm install
npm run build # tsup → dist/ (ESM + .d.ts)
npm run typecheck # vue-tsc, includes the .vue components
npm run verify # ensures nothing escapes the published packageThe TypeScript "logic" layer is compiled to dist/; the Vue components and
stylesheet ship as source and are compiled by the consumer's bundler.
License
MIT © Stoshe Labs
