---
title: "iOS app"
source: "ios/README.md"
---

> Part of [iOS](/ios/llms.txt). Every page: [llms.txt](/llms.txt).

# Shuke iOS

Native iOS app for Shuke (codename). Swift 6 (strict concurrency, warnings are errors), SwiftUI,
iOS 17+, StoreKit 2, Sign in with Apple. The Xcode project is generated by XcodeGen and is not
committed.

Foundation (contract models, networking, auth, audio, uploads, StoreKit, persistence, analytics,
notifications) plus the hi-fi UI from `design/`: onboarding, diagnostic, hard paywall, home, library,
read / record / compress, processing, feedback, settlement, growth, profile & settings. Screenshots
of the key screens in both UI languages live in `docs/screenshots/`.

## Setup

```bash
brew install xcodegen          # once
cd ios
xcodegen generate              # after every pull / when files are added or removed
open Shuke.xcodeproj
```

Requires Xcode 16+ (developed on Xcode 27.1).

## Schemes

| Scheme | What it runs |
|---|---|
| `Shuke` | Debug build against the API in `Config/Debug.xcconfig` (`http://localhost:8787/v1`, i.e. `cd backend && pnpm dev`). Test action runs `ShukeTests` + `ShukeUITests`. Purchases go through RevenueCat (sandbox Apple ID); the scheme's `Shuke.storekit` only works with RevenueCat once the StoreKit testing certificate is uploaded in RevenueCat → app settings. |
| `Shuke-Mock` | Same app launched with `--mock`: fixtures instead of the network, in-memory keychain, simulated microphone, instant uploads. Runs on any simulator with no backend. |

Release builds use `Config/Release.xcconfig` (placeholder `https://api.example.invalid/v1` until a domain is chosen).

### API base URL

`API_BASE_URL` is set per configuration in `Config/*.xcconfig` and read at runtime from the
`ShukeAPIBaseURL` Info.plist key. To point a Debug build somewhere else (e.g. a device on your LAN),
create the git-ignored `Config/Local.xcconfig`:

```
API_BASE_URL = http:/$()/192.168.1.20:8787/v1
```

(`/$()/` is how xcconfig spells `//`.) The bundle id lives in one place: `APP_BUNDLE_ID` in
`Config/Shared.xcconfig`.

### Mock mode

Pass `--mock` (the `Shuke-Mock` scheme does this; Debug builds only). `MockAPIClient` serves the
JSON files in `Shuke/Resources/Fixtures` through a small stateful `MockServer`, so the whole funnel
(onboarding → diagnostic → paywall → session → settlement) runs offline. Every fixture has a zh-Hans
(`home.json`) and an en (`home_en.json`) variant, picked by the UI locale. Scenario switches:

| Argument | Values |
|---|---|
| `--mock-user=` | `new` (onboarding), `answered` (diagnostic), `diagnostic_done` (paywall), `expired` (expired-return paywall), `subscribed` (default) |
| `--mock-home=` | `normal`, `first_time`, `completed`, `in_progress`, `no_quest`, `streak_risk`, `topic_empty`, `error` |
| `--mock-growth=` | `ready`, `empty`, `baseline`, `gap` |
| `--mock-settlement=` | `improved`, `same`, `shorter_lost`, `levelup`, `replayed`, `first` |
| `--mock-feedback=` / `--mock-processing=` / `--mock-exemplar=` | `normal`/`lowconf`/`achieved` · `normal`/`failed`/`unreliable`/`slow` · `ready`/`pending`/`failed` |
| `--mock-mic=` | `granted`, `denied` (default: undetermined → purpose sheet) |
| `--mock-update-required`, `--fast-clock` | force-update screen · 10× recording clock (UI tests) |

Fixtures are generated from the design track's API-shaped seed data (`docs/api/examples/{en,zh-Hans}`)
by `scripts/gen_fixtures.py`, which patches them to the current contract, and are validated against
`docs/api/openapi.yaml` by `scripts/validate_fixtures.py` (strict: unknown keys fail). Release builds
exclude the fixtures folder (`EXCLUDED_SOURCE_FILE_NAMES`) and ignore `--mock`.

```bash
python3 -I ios/scripts/gen_fixtures.py ios/Shuke/Resources/Fixtures
uv run --with jsonschema --with pyyaml python ios/scripts/validate_fixtures.py
```

## Tests

```bash
cd ios
scripts/ci.sh                          # xcodegen + build + unit + UI smoke + string catalog check
SIMULATOR="iPhone 16" scripts/ci.sh    # pick a simulator (default: first available iPhone)
OS_VERSION=26.2 scripts/ci.sh          # pin the simulator OS (default: latest)
scripts/ci.sh build                    # build only
scripts/ci.sh screenshots              # key screens, en + zh-Hans → docs/screenshots/<lang>/*.png
ONLY_TESTING=ShukeTests/PaywallProjectionTests scripts/ci.sh   # a subset
```

or in Xcode: ⌘U on the `Shuke` scheme. Unit tests run hosted in the app, which detects the test
environment and uses mock services (no network, no keychain). There is no GitHub iOS workflow:
`scripts/ci.sh` (fixture validator first, then build + tests) is run locally before every PR.

## Layout

```
ios/
  project.yml              XcodeGen spec (targets Shuke, ShukeTests, ShukeUITests; schemes)
  Config/                  xcconfigs (bundle id, API base URL per configuration)
  Shuke.storekit           local StoreKit config for --mock and tests: clarisay.pro.annual (7-day trial), clarisay.pro.monthly
  Shuke/
    App/                   @main, AppDelegate (APNs, background URLSession, BGTask), AppModel (routing), AppDependencies (composition root)
    Core/Contract/         Codable mirror of every schema in docs/api/openapi.yaml
    Core/API/              Endpoint catalog, APIClient actor, MockAPIClient, errors, paywall signal
    Core/Auth/             Keychain, install id + token store, anonymous bootstrap, Sign in with Apple
    Core/Audio/            RecordingEngine actor (state machine, segments, resume manifest), AVFoundation backends
    Core/Upload/           SHA-256, background-session PUT transport, durable UploadQueue
    Core/Processing/       AttemptPoller (poll_interval_ms / poll_timeout_ms → "check later")
    Core/Store/            StoreService over a PurchaseProvider (RevenueCat live; plain StoreKit for --mock/tests), entitlement cache + 24 h offline grace
    Core/Persistence/      JSON file store (read caches, queues), idempotency keys
    Core/Analytics/        D12 event names, sanitizer, batched durable queue → POST /events
    Core/Config/           /config with 5-min cache, min-version check
    Core/Notifications/    reminder planner (≤1/day, streak-at-risk at 20:00) + scheduler
    Core/Support/          logging, clocks, retry policy, locale preference (Accept-Language)
    DesignSystem/          Tokens.swift (1:1 with design/tokens.css: light/dark/Increase Contrast), L10n + Format, Components/
    Features/              Root, Onboarding, Diagnostic (+ plan), Paywall, Home, Library, Training (read/record/processing/feedback/complete), Growth, Profile
    Resources/             Localizable.xcstrings, InfoPlist.xcstrings, assets, Fixtures/
  ShukeTests/              unit tests
  ShukeUITests/            --mock flows (onboarding → paywall, home → complete) + screenshot capture
  scripts/                 ci.sh, fixture generator/validator, string catalog generator/check
```

## Conventions

- **Contract first.** Models mirror `docs/api/openapi.yaml` exactly (snake_case on the wire via the
  shared `JSONCoding` encoder/decoder; RFC 3339 dates with or without fractional seconds). Server
  enums decode unknown values to `.unknown`; open sets (`APIErrorCode`, `LanguageTag`) keep the raw
  string.
- **Languages (ADR-001 D13).** UI locales `en` (development language) and `zh-Hans`; every key in
  `Localizable.xcstrings` has both (`scripts/check_strings.py` enforces it). Content language is
  per article/session (`LanguageTag`); lengths come from `TextLength` and its server `label` is
  rendered verbatim. Requests send `Accept-Language` = the user's `settings.ui_locale` override or
  the app's current localization. Nothing branches on a specific language.
- **No hard-coded copy in views** — keys only (`Text("tab.home")`, `Text("common.xp_plus \(xp)")`);
  server strings are rendered with `Text(verbatim:)`. `Localizable.xcstrings` is generated from
  `design/strings.json` by `scripts/gen_strings.py`: keys keep the design names, and keys with
  arguments get their format specifiers appended (`common.xp_plus` → `common.xp_plus %lld`) so
  SwiftUI interpolation resolves them. App-only strings live in the script's `EXTRA` table.
  `scripts/check_strings.py` verifies every compiler-extracted key and every literal runtime key
  (`L10n.string`, `L10n.format`, `.dynamic`) exists in both languages.
- **In-app UI language** (`settings.ui_locale`) is applied through `.environment(\.locale)` (every
  `Text`) and `L10n.uiLocale` (runtime lookups); changing it invalidates the API-string caches.
- **Design tokens only** — `Tokens.Color.*`, `Tokens.Font.shuke(_:)`, `Tokens.Spacing.s*`,
  `Tokens.Radius.*`, `Tokens.Motion.*`; haptics via `.sensoryFeedback` in views (engagement.md);
  Reduce Motion through `\.shukeReduceMotion` (system flag or `settings.reduce_motion_override`).
- **Concurrency.** Services are actors; UI-facing models are `@MainActor @Observable`. Time and
  sleeping are injected (`TimeSource`, `Sleeper`) so tests are deterministic.
