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 itstabBarprop. - 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()givesprofile,settings(respectsettings.streamerModewhen showing numbers/names),openApp(id, params),goHome(),notify(),island.set/clear(Live Activities),setBadge(id, n | true),setFullscreen(on),params(what another app passed toopenApp, also notificationdataon a tap).openAppalways mounts a fresh instance of the app, so readparamsonce on mount. Test deep links in the browser with?app=<id>¶ms=<json>. For timers outside React useimport { 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 opensapp(or runsonClick). For running clocks passtimerSince/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 (accentoption / prop to override). - Avatars:
<Avatar name>shows initials for names and a person icon for phone numbers. - Inputs:
TextField/TextArea/SearchFieldonChangereceives 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 sMocks are only loaded outside FiveM. Run npm run dev and open http://localhost:5173/?app=garage. Before committing: npm run typecheck && npm run build.