- name
- stacks-mobile
- description
- Use when building native iOS or Android applications from Stacks and STX with Craft, mobile configuration, native capabilities, device search, health, route recording, safe areas, haptics, or mobile build output. Covers @stacksjs/mobile and the mobile build pipeline.
- license
- MIT
- compatibility
- Bun >= 1.3.0, TypeScript, Xcode for iOS project generation
- allowed-tools
- Read Edit Write Bash Grep Glob
# Stacks Mobile
Stacks mobile applications reuse the same STX views, components, routes, and
API as the web application. Craft owns the platform project and native bridge;
Stacks owns application configuration and build orchestration.
## Key paths
Applications own zero Swift: author STX and TypeScript, configure the shell in
`config/mobile.ts`, and extend reusable native capabilities in Craft. Swift
templates ship in `craft-native`; generated sources under `storage/framework/mobile`
are build output, never application overrides. `buddy build:ios` writes a source
ignore rule and an Xcode Cloud post-clone hook that restores sources from frozen
dependencies. Retain Xcode metadata, generated config, assets and compiled bundles
for cloud discovery. Remove previously tracked Swift with `git rm --cached` when
migrating an existing app; keep the generated files locally for Xcode.
Use the Bun-only `@stacksjs/mobile/testing` entrypoint for
`prepareIosSimulatorDriver(config.ios)`, `serializeSimulatorSteps(steps)`,
`createNativeMutationValidator()` (macOS + swiftc), and `restoreIosShell(output)`.
Configure the reusable simulator host with `ios.simulator` and Bun bootstrap with
`ios.shell.bunVersion`. See the mobile package README for the control-server wire
protocol and Xcode tooling requirements. Browser code must not import `/testing`.
- App configuration: `config/mobile.ts`
- Mobile runtime: `storage/framework/core/mobile/src/`
- Platform build actions: `storage/framework/core/actions/src/build/ios.ts` and `build/android.ts`
- Reusable STX components: `storage/framework/defaults/resources/components/Native*.stx`
- Generated iOS project: `storage/framework/mobile/ios/` (ignored build output)
## Build
```bash
buddy build:mobile
buddy build:ios
buddy build:android
```
`buddy build:mobile` builds both native projects in sequence. Use the
platform-specific commands when iterating on only one native target. The same
targets are available through `buddy build mobile`, `buddy build ios`, and
`buddy build android`.
The build validates `config/mobile.ts`, initializes a Craft iOS project,
selects either a remote application URL or bundled web assets, generates the
Xcode project with xcodegen, and records source, capability, and Craft builder
revision provenance in `stacks-mobile.json`.
xcodegen comes from Pantry, never from `PATH`: the version is
`iosDependencies` in `config/deps.ts` (beside `androidDependencies`; the
framework default is `github.com/yonaskolb/XcodeGen@^2.46.0`), and
`buddy setup:ios` installs it with `pantry install --user`. A machine without
it fails before the build starts, with that command in the error. Xcode is not
a Pantry package; install it from the App Store or developer.apple.com.
For local Craft development, point Stacks at Craft's builder source:
```bash
CRAFT_IOS_SRC=/absolute/path/to/craft/packages/ios/src/index.ts buddy build:ios
CRAFT_ANDROID_SRC=/absolute/path/to/craft/packages/android/src/index.ts buddy build:android
```
`STACKS_IOS_SKIP_XCODEGEN=1` and `STACKS_ANDROID_SKIP_GRADLE=1` are only for
source-level CI and tests. Shippable projects must be generated and compiled
with Xcode or Gradle respectively.
## Configuration
Declare iOS Siri/Spotlight App Intents and home-screen quick actions with
`ios.appShortcuts` (Craft >= 0.0.237). Each entry has `id`, `title`, optional
`subtitle`, `symbol`, an app-scheme `url`, and `phrases` containing exactly one
`{app}` token. Register that scheme in `ios.urlSchemes`. Craft generates and
restores the Swift; never write an application Swift generator. Intents open
their URLs through native routing. Home-screen actions deliver `craftShortcut`
to the app script, which resolves the configured action id to its destination.
For example:
```ts
appShortcuts: [{ id: 'saved', title: 'Saved trails', symbol: 'heart.fill',
url: 'outdoors://profile?tab=saved', phrases: ['Show my saved trails in {app}'] }],
```
```ts
import type { MobileConfig } from '@stacksjs/types'
export default {
ios: {
appName: 'My App',
bundleId: 'com.example.app',
url: 'https://example.com',
fallbackWebAssets: 'dist',
deploymentTarget: '16.0',
orientations: ['portrait'],
urlSchemes: ['myapp'],
capabilities: {
haptics: true,
share: true,
geolocation: true,
secureStorage: true,
},
},
android: {
appName: 'My App',
packageName: 'com.example.app',
url: 'https://example.com',
fallbackWebAssets: 'dist',
capabilities: {
haptics: true,
share: true,
geolocation: true,
secureStorage: true,
},
},
} satisfies MobileConfig
```
Choose exactly one primary content source:
- `url`: load the deployed Stacks application and keep server-rendered routes.
- `webAssets`: bundle a static distribution containing `index.html` and every
referenced asset.
- `fallbackWebAssets`: with `url`, bundle a static distribution that Craft
loads when the remote application is unreachable on cold start.
Only enable capabilities the product uses. Craft turns enabled capabilities
into native bridge availability and required iOS privacy descriptions.
The shell's behaviour around the page defaults to a native app's; set only
what should differ: `swipeBack` (`'router'`, the stx router's interactive
swipe, or `'webview'` for WebKit's history swipe), `keyboardAccessory` (off),
`allowsLinkPreview` (off), `disableZoom` (on), `splashMaxSeconds` (3),
`requestTimeoutSeconds` (10), `backgroundRefresh: { enabled, identifier?,
minimumIntervalMinutes? }`and`associatedDomains` (universal links). Leave
`swipeNavigation` unset: WebKit's swipe alongside the router's fights it, and
`buddy build:ios` warns. The Liquid Glass chrome needs iOS 26 at run time;
the default `deploymentTarget` stays, older systems drawing classic bars.
## Native screen bindings
Screens under `resources/native/` use the same STX client syntax as web views.
Declare reactive data in `<script client>` and bind text with `:text`, props
with `:prop`, and native presses with `@click`. Pull-to-refresh uses `@refresh`
and a boolean `:refreshing` signal. Bare signals auto-unwrap in bindings;
read them with `signal()` and update them with `signal.set()` inside scripts.
Imported helpers used only in bindings are retained in the client setup.
```stx
<script client>
const title = state('Today')
const refreshing = state(false)
async function refresh() {
refreshing.set(true)
try {
const response = await fetch('https://example.com/api/title')
if (!response.ok) throw new Error('Could not refresh')
title.set((await response.json()).title)
}
finally {
refreshing.set(false)
}
}
</script>
<ScrollView @refresh="refresh()" :refreshing="refreshing">
<Text :text="title" />
</ScrollView>
```
A single-brace JSX expression such as `{title}` is plain text in STX. Keep
startup requests and timers in `onMount()`, returning a cleanup for timers so
leaving a native screen releases them. Test the minified `compileNativeBundle`
output with the native bridge, including refresh, keyed rows, conditions and
navigation; compiling a plausible tree alone does not prove it updates.
While an app is in TestFlight before its first public release, keep the iOS
marketing version at `1.0.0` and run `buddy release:ios --bump build`. Each
upload needs a new build number, not a patch to the marketing version.
Each `release:ios` also creates the tag's GitHub release (the TestFlight build
number and the changes since the previous tag), using `GITHUB_TOKEN`,
`GH_TOKEN`, the GitHub CLI's token or the git credential. When that fails the
tag is still out; `buddy release:github <tag>` creates the missing release.
## Device search index
`spotlight` says what of the application's own content a device may index, so
somebody searching their home screen finds a record rather than only finding
the app:
```ts
export default {
ios: { /* ... */ },
spotlight: {
kinds: {
trail: { slots: 24, route: '/trail/:id', noun: 'Trail' },
club: { slots: 8, route: '/club/:id', noun: 'Club' },
},
// Anything the app donates outside the registry: a Siri phrase, an App Intent.
activityTypes: ['favorites', 'trails-near-me'],
},
} satisfies MobileConfig
```
`slots` is a budget, not a guess. iOS indexes donated `NSUserActivity` objects
and hands a tapped one back only for an activity type the build declares in
`Info.plist`, a list fixed at build time - so a type per record id cannot be
declared at all. Each kind gets that many slots, each slot holds whichever
record is currently in it, and the oldest donation makes room for the next.
`buddy build:ios` writes the declarations; an entry the build did not declare
still appears in Spotlight and merely opens the app wherever it was last, which
looks like the feature working until somebody taps a result.
Drive it with one index per application:
```ts
import mobileConfig from '../config/mobile'
import { createSpotlightIndex, onSpotlightTap } from '@stacksjs/mobile'
export const spotlight = createSpotlightIndex({ kinds: mobileConfig.spotlight.kinds })
await spotlight.index('trail', { id: trail.id, name: trail.name }) // one record
await spotlight.sync('club', myClubs) // a list that is theirs, most important first
await spotlight.remove('club', club.id) // left, unsaved, withdrawn from, deleted
await spotlight.clear() // sign-out: none of it is this person's
onSpotlightTap(spotlight, route => location.assign(route))
```
Every call is a no-op that reports as much off a native host, on a build whose
host predates the bridge, and where the index is turned off (`enabled: false`),
so a page can donate unconditionally. `spotlight.routeFor(action)` answers a tap
synchronously for an app that already routes Craft's shortcut events itself.
Index what is the person's - saved, joined, entered - rather than what they
looked at, wherever the page re-renders on that change: a page that donates on
every render would put a record straight back the moment they left it. Donating
the same record twice is free, so an effect over the record is the natural call
site.
## Heart-rate strap (Bluetooth)
`heartRate` reads a Bluetooth heart-rate strap (Polar, Garmin, Wahoo), or a
watch broadcasting its heart rate, through the standard Heart Rate Service.
`scan` finds straps nearby (`onDevice` hears each), `connect(id)` subscribes
to its readings (`onReading`: bpm and RR intervals, about once a second) and
remembers it, so `reconnect()` finds it next time without a scan; a strap that
drops out is asked for again until `disconnect()`. `startRecording()` keeps
every reading natively, screen locked or app closed, until `stopRecording()`
hands them back. iOS only, behind `capabilities.heartRate` (which adds the
`bluetooth-central` background mode); Bluetooth needs no network.
`capabilities.backgroundAudio` (iOS) adds the `audio` background mode, so a
soundscape played with Web Audio keeps going with the screen locked: a sleep
timer built on stx's `useAmbientSound().fadeOut(seconds, after)` then fades it
out on the audio clock. App Review expects audible content from an app that
asks for it.
```ts
import { createRouteRecorder, heartRate, location } from '@stacksjs/mobile'
const stop = heartRate.onDevice(device => list.push(device)) // { id, name, rssi }
await heartRate.scan({ timeout: 15 }) // can show iOS's Bluetooth prompt
await heartRate.connect(list[0].id) // rejects TIMEOUT while still asking
heartRate.onReading(reading => show(reading.bpm))
// With a route: the strap records with it, and each fix gets its heart rate.
const recorder = createRouteRecorder({ location, heartRate })
await recorder.start()
const fixes = await recorder.stop() // fixes[i].heartRate
const samples = recorder.heartRate // every reading: { timestamp, bpm, rr? }
```
Without a route, record it on its own (`heartRate.startRecording()` /
`stopRecording()`), and `attachHeartRate(fixes, samples)` pairs readings
with fixes recorded separately. In a browser and on Android `isAvailable()` is
false, the state says `available: false` and nothing fires. Word iOS's prompt
with `ios.usageDescriptions.NSBluetoothAlwaysUsageDescription`.
## Apple Watch scheduled workouts
`scheduledWorkouts` puts structured workouts on the paired Apple Watch through
WorkoutKit's scheduler (iOS 17+): each appears in the watch's Workout app on
its day, with its warmup, repeated blocks, cooldown, goals and alerts. iOS
only, behind `capabilities.scheduledWorkouts`; only an app with it compiles in
and links WorkoutKit. The calls work on the page and on native stx screens.
A plan is plain JSON in SI units, its `id` a UUID the app keeps for it.
```ts
import { scheduledWorkouts } from '@stacksjs/mobile'
if (await scheduledWorkouts.isSupported()) { // false off an iPhone, never rejects
await scheduledWorkouts.requestAuthorization() // iOS's prompt, once; call it from a tap
const kept = await scheduledWorkouts.list() // [{ id, date, hour, minute, complete }]
await scheduledWorkouts.schedule({
id: '6f1c2b8e-5a7d-4c3e-9b1a-2d4e6f8a0b1c',
date: '2026-10-20', hour: 6, minute: 30, // local; 07:00 without a time
workout: {
activity: 'running', location: 'outdoor', displayName: '4 x 1 km',
warmup: { goal: { type: 'time', seconds: 600 } },
blocks: [{ iterations: 4, steps: [
{ purpose: 'work', step: { goal: { type: 'distance', meters: 1000 }, alert: { type: 'speed', min: 4.1, max: 4.4 } } },
{ purpose: 'recovery', step: { goal: { type: 'time', seconds: 120 } } },
] }],
},
})
await scheduledWorkouts.remove(kept[0].id) // every day it is on
}
```
Goals are `open`, `time` (seconds), `distance` (meters) and `energy`
(kilocalories); alerts `heartRate`, `power`, `speed` (m/s), `cadence` ranges and
`heartRateZone` / `powerZone`. An activity or goal WorkoutKit refuses rejects
the call with a message naming it; an alert it refuses is left out and named
in the result's `droppedAlerts`. `schedule` also rejects LIMIT_REACHED (the
watch holds 15) and NOT_SCHEDULED when nothing kept the plan, as without a
paired watch. In a browser and on Android `isSupported()` is false and every
other call rejects `unsupported`.
For tests, `createScheduledWorkoutsFake()` from `@stacksjs/mobile/testing` is a
`craft.scheduledWorkouts` that answers like the shell. On the iOS simulator,
which never has a watch, the app's `CraftFakeWorkoutScheduler` default makes
the shell keep plans itself (`xcrun simctl spawn <device> defaults write <bundle id> CraftFakeWorkoutScheduler -bool YES`).
## Apple Music (now playing)
`music` shows what the Music app is playing (title, artist, album, a small
artwork data URL, duration, position, playback state) and steers it: `play`,
`pause`, `togglePlayPause`, `skipToNext` and `skipToPrevious`, which restarts
the song a few seconds in, like the Music app's own button. Changes arrive
from native as they happen, so a page never polls. iOS only, behind
`capabilities.appleMusic`; music control is local to the phone, so it works
offline.
```ts
import { music } from '@stacksjs/mobile'
// From the person's tap, never on load: iOS shows its prompt the first time.
if (await music.requestAuthorization() === 'authorized') {
render(await music.getNowPlaying())
const stop = music.onChange(render) // observes while anyone listens
await music.togglePlayPause()
}
```
Only Apple Music: iOS gives no app the controls of another player, Spotify
included, so while one plays the song fields are null and `otherAudioPlaying`
is true; say so rather than show controls that do nothing. Nothing is read
before access is allowed (`authorization` is `notDetermined`, `denied` or
`restricted` until then). In a browser and on Android `isAvailable()` is false,
every call answers `{ available: false }` and `onChange` never fires. Word the
prompt with `ios.usageDescriptions.NSAppleMusicUsageDescription`. The
simulator's Music app has nothing to play; launch a simulator build with
`-CraftFakeMusicPlayer YES` for three made-up songs that play and skip.
## Crash reporting (bughq)
`crashReporting` in `config/mobile.ts` turns on Craft's native reporter (craft-native >= 0.0.179; the build refuses an older one). It sends crashes, hangs (MetricKit), ANRs and native crashes (ApplicationExitInfo) and uncaught JavaScript errors in native screens and the app script to bughq from an on-disk outbox, with app version and build, OS, device model, session and breadcrumbs (screens shown, failed requests).
```ts
export default {
crashReporting: { host: env.BUGHQ_HOST || undefined, environment: 'production' },
View on GitHub