- 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`, compiles configured STX native screens,
initializes the Craft platform project and records source, capability and builder
revision provenance in `stacks-mobile.json`. With `renderer: 'native'`, Craft
draws platform controls from the compiled bundle without a WebView. The same STX
components and TypeScript behavior can also serve browser routes; keep browser
route adapters thin and share the component implementation. Native capabilities
still depend on the host, OS, hardware and permission.
With `renderer: 'web'`, Craft loads a remote page or bundled web assets. This is
a separate renderer configuration. A native renderer's `url` supplies its API
and fallback browser origin; it does not fetch the native UI from that URL at
launch. Changes to bundled screens, native code or capabilities require a new
app build. API data refreshes independently.
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: {
renderer: 'native',
nativeScreens: { '/m/': 'Home' },
nativeScreensDir: 'resources/components',
appName: 'My App',
bundleId: 'com.example.app',
url: 'https://example.com',
deploymentTarget: '16.0',
orientations: ['portrait'],
urlSchemes: ['myapp'],
capabilities: {
haptics: true,
share: true,
geolocation: true,
secureStorage: true,
},
},
android: {
renderer: 'native',
nativeScreens: { '/m/': 'Home' },
nativeScreensDir: 'resources/components',
appName: 'My App',
packageName: 'com.example.app',
url: 'https://example.com',
capabilities: {
haptics: true,
share: true,
geolocation: true,
secureStorage: true,
},
},
} satisfies MobileConfig
```
For the native renderer, every app-owned route must map to an STX screen in
`nativeScreens` on both platforms. The example maps `/m/` to
`resources/components/Home.stx`; browser routes should render that same shared
component rather than duplicate it. Compile and test the actual screen bundle
for both targets. External authorization or checkout routes may use the system
browser with an explicit return path.
For the web renderer, choose a 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`: optionally pair with `url` to load bundled web assets
when the remote page is unreachable on cold start. This does not supply an
offline native UI; native screens are already in their compiled bundle.
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 in `nativeScreensDir` (default `resources/native/`) use the same STX
client syntax as web views. Point it at the shared component directory when
those components also serve browser routes.
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.
Testers' notes come with every release. From the conventional commits since
the previous `v*-build.*` tag (logsmith's parser, so the same sections as the
changelog; `chore`, `ci`, `test`, `docs` and `chore(release)` left out),
`release:ios` writes `storage/framework/mobile/ios/TestFlight/WhatToTest.<locale>.txt`
(en-US plus `ios.nativeMessages` languages, under 1000 characters) into the
release commit, where Xcode Cloud reads it, and puts the list in the GitHub
release. Overrides, first wins: `--notes` / `--notes-file`, `RELEASE_NOTES` /
`RELEASE_NOTES_FILE`, `release.notes` / `release.notesFile` in
`config/mobile.ts`. `--dry-run` prints them. `--wait [minutes]` (with an App
Store Connect key) also sets What to Test through the API once the build
appears; `buddy release:notes <tag> --testflight` does it later, and
`buddy release:notes <tag> --play` prints the 500-character Google Play text.
The code is `@stacksjs/actions/release-notes`.
## 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
عرض على GitHub