Skip to main content

stacks-mobile

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.

Source facts

Repository
stacksjs/stacks
Last source activity
October 17, 2026 at 19:44
Detected SKILL.md language
English
Stars
633
Forks
20

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub