Skip to content

Writing an app ​

Every folder in web/src/apps/<id>/ is discovered automatically (no registry to edit). An app has three files:

web/src/apps/garage/
  manifest.ts   icon, placement, lazy entry
  App.tsx       default export = root screen
  mock.ts       browser-only fake backend (optional but expected)

manifest.ts ​

ts
import { defineApp } from '@/os/apps'

export default defineApp({
  id: 'garage',                      // = folder name, also the rpc namespace prefix
  name: 'Garage',
  icon: 'fa-solid fa-warehouse',     // Font Awesome 6 Free classes
  tint: 'gray',                      // AppTint: gray red orange rust pinkf olivef rose green blue gold violet dark
  component: () => import('./App'),  // lazy: the app's code is only loaded when opened
  placement: { home: 5 },            // { home: n } | { dock: n } | 'store'
  accent: '#0a84ff',                 // optional, defaults to the tint colour (system blue for gray/dark)
  description: 'Your vehicles',      // optional, App Store listing (store apps)
  service: () => import('./service'), // optional background service (see below)
})

manifest.ts must stay side-effect free (no import './something' for its effects): everything that has to run while the app is closed goes into a service:

ts
// web/src/apps/garage/service.ts — started once by the shell after init, app open or not
import { onPush } from '@/lib/bus'
import type { OSHandle } from '@/os'

export default function startGarageService(os: OSHandle) {
  const off = onPush('garage:impounded', (d) => os.island.set('garage', { icon: 'fa-solid fa-car', title: 'Impounded', app: 'garage' }))
  return off // optional cleanup
}

Reserved home indices and dock slots are listed in web/src/os/apps.ts. Use the index you were assigned; gaps are fine. 'store' apps only appear on the home screen once installed via the App Store.

App.tsx ​

tsx
import { useState } from 'react'
import { useOS, useNav } from '@/os'
import { rpc, useRpc, usePush } from '@/lib/rpc'
import { AppScreen, LargeTitle, SearchField, List, ListRow, NavBar, AppIcon, EmptyState, Spinner, Button, confirm } from '@/ui'

interface Vehicle { plate: string; model: string; state: 'garaged' | 'out' | 'impounded' }

export default function Garage() {
  const { data, loading, reload } = useRpc<Vehicle[]>('garage.list')
  const nav = useNav()
  usePush('garage:update', () => reload())        // server push → refresh
  if (loading) return <AppScreen><Spinner center /></AppScreen>
  return (
    <AppScreen>
      <LargeTitle>Garage</LargeTitle>
      <List>
        {data?.map((v) => (
          <ListRow key={v.plate} title={v.model} subtitle={v.plate} chevron onClick={() => nav.push(<Detail v={v} />)} />
        ))}
      </List>
    </AppScreen>
  )
}

function Detail({ v }: { v: Vehicle }) {
  const os = useOS()
  return (
    <AppScreen>
      <NavBar back="Garage" title={v.plate} />
      <Button variant="fill" full onClick={async () => {
        if (!(await confirm({ title: 'Track vehicle?', message: '$50 fee' }))) return
        await rpc('garage.track', { plate: v.plate })        // throws RpcError(code) on { ok:false }
        os.notify({ app: 'garage', title: 'Garage', body: 'Waypoint set' })
      }}>Track</Button>
    </AppScreen>
  )
}

Rules of thumb:

  • Every screen is an <AppScreen>. It handles the status-bar safe area, home indicator padding, scrolling and side padding (padded={false} / scroll={false} to opt out). Put a <TabBar> into its tabBar prop.
  • Navigation: const nav = useNav() → nav.push(<Screen/>), nav.pop(), nav.replace(), nav.reset(). Pushed screens slide in; <NavBar back /> pops (also edge swipe). Keep screen state in the screen.
  • Data: rpc<T>(method, params) for actions, useRpc<T>(method, params) for loading ({ data, loading, error, reload, setData }), usePush<T>('area:event', fn) for live updates. Methods are namespaced per area (see Architecture §8).
  • Accent: var(--accent) is set per app from the manifest; NavBar buttons, tabs, Button, toggles use it. Override per screen with <AppScreen accent="#e98a52">.
  • OS: useOS() gives profile, settings (respect settings.streamerMode when showing numbers/names), openApp(id, params), goHome(), notify(), island.set/clear (Live Activities), setBadge(id, n | true), setFullscreen(on), params (what another app passed to openApp, also notification data on a tap). openApp always mounts a fresh instance of the app, so read params once on mount. Test deep links in the browser with ?app=<id>&params=<json>. For timers outside React use import { os } from '@/os'.
  • Live Activities: os.island.set(id, { icon | avatar: { name, tint }, title, subtitle, trailing, timerSince | timerUntil, app, actions }). Tap on the compact island expands it, tap on the expanded card opens app (or runs onClick). For running clocks pass timerSince / timerUntil (epoch ms) and let the shell tick — don't re-set the activity every second.
  • Full-screen overlays (camera-style HUD outside the phone frame): os.setFullscreen(true) + render into <FullscreenPortal>. The phone frame hides, your app stays mounted, closing the phone doesn't auto-lock.
  • Lifecycle: with a passcode the phone auto-locks on close, but your app stays mounted under the lock screen (state kept) for Config.KeepAppMinutes; don't rely on unmount = phone closed.
  • Dialogs: await confirm({...}), await prompt({...}), await actionSheet({...}), or <Sheet open onClose title>. They use your app's accent automatically (accent option / prop to override).
  • Avatars: <Avatar name> shows initials for names and a person icon for phone numbers.
  • Inputs: TextField/TextArea/SearchField onChange receives the string. Focus/keyboard handling for walk mode is automatic.
  • Styling: CSS modules next to your components (garage.module.css), theme variables from @/ui/theme.css (--card-solid, --muted, --sep, --fill, --accent, --green, --red…). Dark only. No external assets (NUI must work offline) — use Font Awesome icons and CSS.

mock.ts (browser dev only) ​

ts
import { mock, mockPush } from '@/lib/mock'
import { RpcError } from '@/lib/rpc'

const vehicles = [{ plate: '46EEK572', model: 'Sultan RS', state: 'garaged' }]
mock('garage.list', () => vehicles)
mock<{ plate: string }>('garage.track', ({ plate }) => {
  if (!vehicles.some((v) => v.plate === plate)) throw new RpcError('not_found')
  return true
})
mockPush('garage:update', { plate: '46EEK572' }, 8000) // simulate a server push after 8 s

Mocks are only loaded outside FiveM. Run npm run dev and open http://localhost:5173/?app=garage. Before committing: npm run typecheck && npm run build.

np_* FiveM resources