# App Module Contract The rules every app obeys to integrate with the MobileGym OS. The OS auto-discovers apps at compile time; in exchange, each app honors a small set of file conventions, runtime hooks, and stylistic constraints. This document is the formal version of [getting-started/add-an-app](../../guides/add-an-app.md) — read the tutorial first if you haven't. ## File layout An app lives under `apps//` (third-party / daily app) or `system//` (system app). The OS treats both directories identically; they're separated only for organization. ``` apps// ├── manifest.ts # required — identity, theme, intent filters ├── App.tsx # required — entry component, *App.tsx pattern, default export ├── navigation.declaration.ts # required — FSM of routes / transitions / actions ├── navigation.ts # required — go() / back() helpers ├── navigation.types.ts # required — local NavigationDeclaration type ├── state.ts # optional — Zustand store (auto-discovered) ├── res/ # resources — colors, dimens, strings, icons │ ├── icons.tsx # required if you use any icons by name │ ├── colors.ts # optional — Tier-2 component colors │ ├── dimens.ts # optional — reused layout sizes │ ├── strings.ts # optional — string table (zh default, .en for English) │ └── colors.states.ts # optional — dark-mode overrides ├── assets/ # binary resources (imported, not URL-loaded) ├── data/ │ ├── index.ts # merge constants + defaults → _CONFIG │ ├── defaults.json # replaceable initial runtime state │ └── *.json or loader.ts # optional — large world data (lazy-loaded) ├── types.ts # app-level TypeScript types ├── constants.ts # structural constants (tabs, feature flags) ├── context/Context.tsx # optional — React Context provider for app state ├── hooks/ │ └── useGestures.ts # app-specific gesture hook wrapping useTriggerGestures ├── pages/ # page components └── components/ # shared in-app components ``` Anything outside this contract is up to you. The framework only cares about the bolded "required" files plus a couple of cross-cutting conventions described below. ## `manifest.ts` The identity card. Mirrors the Android `AndroidManifest.xml` plus theme info. ```ts import type { AppManifest } from '@/os/types/manifest'; import { IcLauncher } from './res/icons'; export const manifest: AppManifest = { id: 'habits', // appId — unique, also the localStorage key packageName: 'com.example.habits', displayName: '习惯', displayNameEn: 'Habits', // injected into OS i18n; no os/i18n/en.ts edit aliases: ['habit tracker'], // injected into OS app lookup version: '1.0.0', versionCode: 1, type: 'plugin', // 'plugin' for daily apps, 'system' for system apps icon: IcLauncher, iconBackground: '#10b981', iconForeground: '#ffffff', designViewportWidth: 360, // anchor for CSS-zoom display scaling theme: { colors: { // Tier-1 semantic colors → CSS variables primary: '#10b981', primaryDark: '#0e9e74', background: '#f6f7f9', surface: '#ffffff', textPrimary: '#0f172a', textSecondary: '#64748b', border: '#e2e8f0', statusBarForeground: 'dark', navigationBarForeground: 'dark', }, colorsDark: { /* optional dark-mode overrides */ }, }, intentFilters: [ // what this app receives { action: 'ACTION_SEND', type: 'text/plain', route: '/share', description: 'Receive shared text' }, ], queries: [ // what this app emits (so OS can resolve our outbound intents) { action: 'ACTION_VIEW', scheme: 'https' }, ], }; ``` Rules: - `id` is the **appId** — every cross-reference (`__OS__.openApp('habits')`, the localStorage key, `apps.habits` in `__SIM__.getState()`) uses this string. Lowercase, no spaces. - The directory name doesn't have to equal `id` — `apps/Wechat/` has `id: 'wechat'`. OS maps directory → id via the manifest. - `theme.colors` gets injected as CSS custom properties (`--app-primary`, `--app-text-primary`, …). Tailwind classes like `text-app-primary` are wired in `app.css` via Tailwind v4 `@theme inline`. - `displayNameEn` is auto-merged into the OS English string table — you don't edit `os/i18n/en.ts`. - `intentFilters` declares the intents you can receive; `queries` declares the intents you might send (so the OS can statically know your outbound surface). ## `App.tsx` The entry component. The filename **must** end in `App.tsx` and the component **must** be the file's default export — that's how `import.meta.glob('apps/*/*App.tsx')` finds it. ```tsx import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; import { useAppNavigationHandler } from '@/os/hooks/useAppNavigationHandler'; import { manifest } from './manifest'; import { useAppNavigate } from './navigation'; import HomePage from './pages/HomePage'; import NewHabitPage from './pages/NewHabitPage'; function HabitsAppInner() { const location = useLocation(); const { back } = useAppNavigate(); useAppNavigationHandler(manifest.id, { onBack: () => { if (location.pathname === '/' && !location.search) return false; back(); return true; }, }); return ( } /> } /> ); } export default function HabitsApp() { return ( ); } ``` Two non-obvious rules: 1. **`useAppNavigationHandler` must be called inside the `MemoryRouter`.** It registers your app's navigator with `AppNavigatorRegistry` (so the OS can `openApp(id, '/some/path')`), wires the app-level back handler (priority 100) into `BackDispatcher`, broadcasts lifecycle events into `AppLifecycle`, and keeps a shadow `HistoryTracker` in sync so `popTo()` works. 2. **No `useNavigate()` from `react-router`.** Use the per-app `go()` / `back()` helpers from `navigation.ts`. The reason is in [`../navigation/declaration.md`](../navigation/declaration.md). ## `navigation.types.ts` and `navigation.declaration.ts` Each app keeps a **local copy** of the `NavigationDeclaration` type. Decoupling per app: when the type evolves, you migrate apps one at a time instead of breaking the world. ```ts // apps/Habits/navigation.types.ts export interface NavigationDeclaration { app: string; routes: RouteDeclaration[]; transitions: TransitionDeclaration[]; capabilities: { historyBack: boolean }; // ... } ``` Then: ```ts // apps/Habits/navigation.declaration.ts import type { NavigationDeclaration } from './navigation.types'; export const HabitsNavigation = { app: 'habits', routes: [ { path: '/', component: 'HomePage', params: {}, entryPoint: 'home', uiStates: [{ id: 'habits.home', search: {}, description: 'Home' }], queryParams: {}, description: 'Home', }, { path: '/new', component: 'NewHabitPage', params: {}, entryPoint: 'none', uiStates: [{ id: 'habits.new', search: {}, description: 'New habit' }], queryParams: {}, description: 'New habit', }, ], transitions: [ { id: 'habits.open-new', from: 'habits.home', to: '/new', search: {}, searchParams: {}, params: {}, mode: 'push', label: 'Open new habit', ui: { placement: 'fab', icon: 'plus', gesture: 'tap' }, }, ], capabilities: { historyBack: true }, } as const satisfies NavigationDeclaration; ``` Why `as const satisfies` rather than `: NavigationDeclaration`: keeps the literal types (so transition ID autocompletion works downstream) while still type-checking against the declaration shape. The full grammar — discrete `uiStates`, `queryParams`, `cases`, `preserveParams`, `actions`, conditions — is in [`../navigation/declaration.md`](../navigation/declaration.md). The minimal claim here is: **every route, every transition, every action your app can do is statically declared in this file**. ## `state.ts` (optional but standard) If your app has any in-memory state, create it through the MobileGym app-store factory so the OS can discover, snapshot, reset, and adapt it: ```ts // apps/Habits/state.ts import { createAppStoreWithActions } from '@/os/createAppStore'; import { HABITS_CONFIG } from './data'; type HabitsState = { habits: Array<{ id: string; doneToday: boolean }>; }; type HabitsActions = { toggleDay: (id: string) => void; }; export const useHabitsStore = createAppStoreWithActions( 'habits', { habits: HABITS_CONFIG.habits }, (set) => ({ toggleDay: (id) => set((s) => ({ habits: s.habits.map(h => h.id === id ? { ...h, doneToday: !h.doneToday } : h), })), }), ); ``` Two contracts: 1. **The first `createAppStore*` argument is the appId** — it must equal `manifest.id`. It is also the default localStorage key. 2. **Factory registration** — `createAppStore` / `createAppStoreWithActions` registers the store in the app-store registry. The OS eagerly imports `apps/*/state.ts` and `system/*/state.ts`, but a bare Zustand store is not enough for snapshots. If you want fine-grained control over what gets exposed to `__SIM__.getState()` and what gets reset by `__SIM__.reset()`, use `registerStateAdapter` (see [`../state/model.md`](../state/model.md)). ## `data/` — config-first conventions The data layer separates **structural constants** (this app's *shape*) from **replaceable runtime state** (this user's *content*). ``` apps/Habits/data/ ├── index.ts # merge entry point └── defaults.json # replaceable initial state ``` `data/index.ts`: ```ts import defaults from './defaults.json'; import { manifest } from '../manifest'; import { HABIT_CATEGORIES } from '../constants'; export const HABITS_CONFIG = { ...defaults, appId: manifest.id, categories: HABIT_CATEGORIES, }; ``` What goes where: | Where | What | |---|---| | `constants.ts` | Tab definitions, service grids, feature flags — anything **the user can't change**. | | `data/defaults.json` | The initial state for things the user *can* change: their profile, their content, their settings. | | `apps//data/*.json` (and `loader.ts`) | Optional **large world data** — public posts, products, stations, places. Lazily loaded; **not** part of snapshots. | The full rules for state layering (and why "world data" lives separately) are in [`../state/model.md`](../state/model.md). ## Resources (`res/`) Aligned with Android's `res/values/*` philosophy but smaller. ### `res/icons.tsx` ```ts import { CreditCard, Bus } from 'lucide-react'; export const IcCard = CreditCard; export const IcBus = Bus; export const ICON_REGISTRY = { IcCard, IcBus }; ``` Rules: - Every exported icon is prefixed `Ic*`. - `ICON_REGISTRY` keys match the export names exactly (also `Ic*`). - Inside JSX with a fixed icon, write ``. - Inside data files (`constants.ts`, `defaults.json`), reference by name: `"icon": "IcCard"`. Then render with ``. - **No raw Lucide names in data files.** A `"icon": "CreditCard"` is a bug — fix the data, don't patch the registry. ### Colors and dimensions `theme.colors` in `manifest.ts` is **Tier 1** (semantic, exposed as CSS vars). For most apps that's all you need; reach for Tailwind classes (`text-app-primary`, `bg-white`) directly. #### Tier-1 color tokens (the canonical names) Every app declares these eight semantic colors (some are optional — listed below). The OS exposes each one as a CSS variable and wires Tailwind utility classes against it: | `theme.colors` key | CSS var | Tailwind class | Required? | Purpose | |---|---|---|:---:|---| | `primary` | `--app-primary` | `text-app-primary`, `bg-app-primary`, `border-app-primary` | ✅ | Brand primary — accents, CTAs, active tabs | | `primaryDark` | `--app-primary-dark` | `bg-app-primary-dark` | ✅ | Pressed / hover state of `primary` | | `onPrimary` | `--app-on-primary` | `text-app-on-primary` | optional | Foreground on `primary` background (defaults to white) | | `background` | `--app-bg` | `bg-app-bg` | ✅ | Page background (the chrome behind cards) | | `surface` | `--app-surface` | `bg-app-surface` | ✅ | Card / sheet / list-row background | | `textPrimary` | `--app-text` | `text-app-text` | ✅ | Default body text | | `textSecondary` | `--app-text-muted` | `text-app-text-muted` | ✅ | Captions, hints, timestamps | | `border` | `--app-border` | `border-app-border` | ✅ | Hairline separators, card borders | Status / nav-bar chrome (lifted to the manifest so the OS chrome renders before the app does): | `theme.colors` key | Values | Purpose | |---|---|---| | `statusBarForeground` | `'dark'` \| `'light'` | Foreground color of the OS status bar over this app's chrome | | `navigationBarForeground` | `'dark'` \| `'light'` | Foreground color of the OS gesture bar | Optional dark-mode overrides go in `theme.colorsDark` using the **same keys** — only declare the ones that change: ```ts theme: { colors: { primary: '#10b981', primaryDark: '#0e9e74', background: '#f6f7f9', surface: '#ffffff', textPrimary: '#0f172a', textSecondary: '#64748b', border: '#e2e8f0', statusBarForeground: 'dark', navigationBarForeground: 'dark', }, colorsDark: { background: '#1a1a1a', surface: '#2a2a2a', textPrimary: '#e5e5e5', }, } ``` For one-off page-level overrides of status/nav bar foreground (different page in the same app), use the per-page `data-status-bar-foreground` / `data-navigation-bar-foreground` attributes — see [Cross-cutting rules § 5](#5-status-bar-reserve). Add `res/colors.ts` only when you have: - A color a CSS-var name can't express (a 3-stop gradient, a brand-specific shade reused across components). - A color you need to swap between light/dark mode programmatically. Add `res/dimens.ts` only when a layout dimension is **reused in 3+ places** (e.g. list item heights consumed by `react-virtual`). One-off sizes belong inline. > ⚠️ **Pixel math must use CSS vars or `h-[Npx]`, never Tailwind rem classes.** If you compute `scrollTop = i * itemHeight` in JS, the element's height must come from a CSS variable or arbitrary pixel value — not `h-10` / `h-14`. Browsers with a non-16-px default font size break rem-to-pixel mapping, and your JS scroll offsets drift. ### Strings `res/strings.ts` is optional. If you have user-visible strings that need i18n, drop them there with `STR.foo` and an optional `strings.en.ts` override file. App code selects the right variant with `useAppStrings(strings, stringsEn)`, which reads `OsStateStore.settings.global.language` through `useLocale()`. ## Cross-cutting rules Some constraints apply uniformly across every app. They're worth memorizing because the lint rules and code review will reject violations. ### 1. Time, location, network — through OS services only ```ts // ❌ blocked by lint const t = Date.now(); const d = new Date(); // ❌ blocked by platform convention / code review navigator.geolocation.getCurrentPosition(...); fetch('https://external.example.com/api'); // ✅ correct import * as TimeService from '@/os/TimeService'; import * as LocationService from '@/os/LocationService'; import { netJson } from '@/os/NetworkService'; const t = TimeService.now(); const realT = TimeService.realNow(); // when you need real elapsed time const coords = LocationService.getSimulatedCoords(); // LocationCoords | null const data = await netJson('https://external.example.com/api'); ``` Why: the benchmark needs to replay deterministic time, override location for reproducibility, and route external HTTP through a same-origin gateway with a per-session cookie jar. Direct browser APIs defeat all three. ### 2. Discrete UI states go through the URL Tabs, modals, sheets, drawers — anything that's "in a state, then in another state" — push or replace through `go()` with a search-param change. Never use `useState` for visibility. The back key listens to history, not React state. A `useState`-driven dialog **leaks through back press**: the user pushes back, the dialog stays open, the previous page navigates away. Push a `searchParams` entry instead; closing the dialog is just `back()`. ### 3. No direct `BackDispatcher` import `BackDispatcher` is an OS internal. Apps interact with the back key through the URL stack and through their own `back()` helper. ### 4. Pointer events for drag / swipe / slider ```ts // ✅ onPointerDown, onPointerMove, onPointerUp, onPointerCancel // + setPointerCapture(e.pointerId) when continuous tracking matters // ❌ onTouchStart + onMouseDown side-by-side touchmove + click as desktop fallback ``` Mixing the two event families silently misroutes input on hybrid devices (touch laptops, Playwright with `--mobile`). Pointer events unify both. ### 5. Status bar reserve Every page's outermost element needs `pt-10`. If the chrome's foreground (white text on dark, dark text on light) differs from the default, set `data-status-bar-foreground="dark|light"` on that same outer element. ### 6. Keyboard auto-resize, no fixed bottom bars The OS implements `adjustResize`: when the keyboard is up, the active Activity container shrinks by the keyboard height. Flex layouts automatically reflow. Don't use `position: fixed; bottom: keyboardHeight` for chat input bars — the CSS-zoom anchor (`designViewportWidth`) and the keyboard height interact badly. Use a flex layout with `flex-shrink-0` for the input bar and let `adjustResize` do its job. If you have elements that should disappear when the keyboard is up (a bottom TabBar that doesn't make sense above the keyboard), add `data-hide-on-keyboard` and the OS hides them via global CSS. For the full keyboard, IME, smart-scroll, and pointer-event contract, see [`../os/services/input-keyboard.md`](../os/services/input-keyboard.md). ## How discovery works (so you can debug failures) If your app doesn't show up, walk through the checklist: 1. **Filename** — entry component must end in `App.tsx` (for example `HabitsApp.tsx`). The glob matches `*App.tsx`; lowercase prefixes are technically matched too, but use the app's PascalCase name for consistency. 2. **Default export** — the component must be `export default`. Named exports are ignored. 3. **Manifest export** — `apps//manifest.ts` exports `export const manifest = …`. A wrong export name is silently skipped. 4. **`id` uniqueness** — duplicate `id`s across two manifests are invalid and can cause one manifest to overwrite another in package lookup maps. Search `apps/ system/ -name manifest.ts` if you suspect collision. 5. **Vite cache** — after adding a new file, restart `npm run dev`. `import.meta.glob` is resolved at build time; HMR catches edits but not new entries. ## Foreign-task isolation (advanced) When `startActivityForResult` pushes an Activity from app B onto app A's Task (e.g. Alipay pushed onto 12306 for a payment), app B may be mounted in a foreign Task context. If app B already has its own background Task, both contexts can exist simultaneously: - App B's "own" task (background, only if it already existed) - App A's task with App B pushed on top `useAppNavigationHandler` detects the foreign case (`task.rootAppId !== appId`) and **skips** the app-level navigator/back/lifecycle registration. Only the Activity-level navigator (registered separately by the activity-specific handler) is used. This keeps the background instance's registrations intact. You usually don't need to think about this — it Just Works — but if you're debugging "why did calling `goBack()` in my app navigate the wrong app's URL," foreign-task isolation is the place to look. ## Where to go next - 🎯 The declarative navigation grammar in depth → [`../navigation/declaration.md`](../navigation/declaration.md) - 🗃️ State, snapshots, world data vs. runtime overlay → [`../state/model.md`](../state/model.md) - 🚧 Cross-app calls (intents, launchMode, choosers) → [`../os/intent-system.md`](../os/intent-system.md) - 🧭 Cross-app Task/back-stack traces → [`../os/cross-app-launch.md`](../os/cross-app-launch.md) - 🛰️ The OS services your app consumes → [`../os/services/`](../os/services/) - 📱 Tutorial walkthrough → [`../../guides/add-an-app.md`](../../guides/add-an-app.md)