21 KiB
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 — read the tutorial first if you haven't.
File layout
An app lives under apps/<Name>/ (third-party / daily app) or system/<Name>/ (system app). The OS treats both directories identically; they're separated only for organization.
apps/<Name>/
├── manifest.ts # required — identity, theme, intent filters
├── <Name>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 → <APPNAME>_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/<Name>Context.tsx # optional — React Context provider for app state
├── hooks/
│ └── use<Name>Gestures.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.
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:
idis the appId — every cross-reference (__OS__.openApp('habits'), the localStorage key,apps.habitsin__SIM__.getState()) uses this string. Lowercase, no spaces.- The directory name doesn't have to equal
id—apps/Wechat/hasid: 'wechat'. OS maps directory → id via the manifest. theme.colorsgets injected as CSS custom properties (--app-primary,--app-text-primary, …). Tailwind classes liketext-app-primaryare wired inapp.cssvia Tailwind v4@theme inline.displayNameEnis auto-merged into the OS English string table — you don't editos/i18n/en.ts.intentFiltersdeclares the intents you can receive;queriesdeclares the intents you might send (so the OS can statically know your outbound surface).
<Name>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.
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 (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/new" element={<NewHabitPage />} />
</Routes>
);
}
export default function HabitsApp() {
return (
<MemoryRouter>
<HabitsAppInner />
</MemoryRouter>
);
}
Two non-obvious rules:
useAppNavigationHandlermust be called inside theMemoryRouter. It registers your app's navigator withAppNavigatorRegistry(so the OS canopenApp(id, '/some/path')), wires the app-level back handler (priority 100) intoBackDispatcher, broadcasts lifecycle events intoAppLifecycle, and keeps a shadowHistoryTrackerin sync sopopTo()works.- No
useNavigate()fromreact-router. Use the per-appgo()/back()helpers fromnavigation.ts. The reason is in../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.
// apps/Habits/navigation.types.ts
export interface NavigationDeclaration {
app: string;
routes: RouteDeclaration[];
transitions: TransitionDeclaration[];
capabilities: { historyBack: boolean };
// ...
}
Then:
// 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. 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:
// 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<HabitsState, HabitsActions>(
'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:
- The first
createAppStore*argument is the appId — it must equalmanifest.id. It is also the default localStorage key. - Factory registration —
createAppStore/createAppStoreWithActionsregisters the store in the app-store registry. The OS eagerly importsapps/*/state.tsandsystem/*/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).
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:
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/<Name>/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.
Resources (res/)
Aligned with Android's res/values/* philosophy but smaller.
res/icons.tsx
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_REGISTRYkeys match the export names exactly (alsoIc*).- Inside JSX with a fixed icon, write
<IcCard size={22} />. - Inside data files (
constants.ts,defaults.json), reference by name:"icon": "IcCard". Then render with<IconRenderer name={item.icon} size={22} />. - 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:
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.
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 computescrollTop = i * itemHeightin JS, the element's height must come from a CSS variable or arbitrary pixel value — noth-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
// ❌ 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
// ✅
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.
How discovery works (so you can debug failures)
If your app doesn't show up, walk through the checklist:
- Filename — entry component must end in
App.tsx(for exampleHabitsApp.tsx). The glob matches*App.tsx; lowercase prefixes are technically matched too, but use the app's PascalCase name for consistency. - Default export — the component must be
export default. Named exports are ignored. - Manifest export —
apps/<Name>/manifest.tsexportsexport const manifest = …. A wrong export name is silently skipped. iduniqueness — duplicateids across two manifests are invalid and can cause one manifest to overwrite another in package lookup maps. Searchapps/ system/ -name manifest.tsif you suspect collision.- Vite cache — after adding a new file, restart
npm run dev.import.meta.globis 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 - 🗃️ State, snapshots, world data vs. runtime overlay →
../state/model.md - 🚧 Cross-app calls (intents, launchMode, choosers) →
../os/intent-system.md - 🧭 Cross-app Task/back-stack traces →
../os/cross-app-launch.md - 🛰️ The OS services your app consumes →
../os/services/ - 📱 Tutorial walkthrough →
../../guides/add-an-app.md