Files
element-x-ios/AGENTS.md
Sedat Qaja 8277cfb88c docs(healthchat): add the upstream sync skill
Patch-stack model, LFS handling, conflict hot spots, verification matrix, force-push and rewind, collected from the 26.09.1 → 26.10.3 sync.
2026-10-19 09:55:44 +02:00

421 lines
19 KiB
Markdown

# AGENTS.md — Element X iOS
> **Repo:** `element-hq/element-x-ios` — iOS Matrix client (SwiftUI + `matrix-rust-sdk`).
> **Keep current:** Change break fact here — path, command, convention, structure? Fix that fact, same PR. Change not described here? Leave file alone. Edit file? Use caveman skill — match terse voice. No caveman, no edit.
---
## Strong Conventions
PRs must follow rules. Prefer Xcode MCP tools over terminal commands.
### Code Style
- **SwiftLint** (.swiftlint.yml) + **SwiftFormat** (.swiftformat) enforce style on build. Warnings show in Xcode. Build before **XcodeRefreshCodeIssuesInFile**.
- Follow [Swift API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/) everywhere, Rust SDK wrappers too (`ID` not `Id`, `URL` not `Url`, `configuration` not `config` or `cfg`).
- File headers live in `IDETemplateMacros.plist`.
- **Import `SwiftUI`, not `UIKit`** — need `UIPasteboard` etc, `SwiftUI` bring it. `SwiftUI` re-export `Foundation` too, so no `import Foundation` beside it.
- **Clean Code** (Robert C. Martin) Newspaper order: headline first, detail down. Stepdown rule: caller above callee, calls point down. Tests: test cases first, helpers below. Swift idiom always win on clash.
- **Member order in type**: properties (stored + computed, public + private) → `init` → functions. `View` types: properties → `init` → views (`body` first) → functions.
### Comments
- **Code speak for itself** — doc comments fine, but no comments restating what clear code already says.
- **Do** comment when code hides trap, hard to follow, or choice needs justifying.
- Comments short — one line where possible. Exceed only when truly needed.
### PII & Logging
- Default: `MXLog.info`. Unexpected failures: `.error`. Noisy dev logs: `.verbose`. `.failure`/`.debug` rare.
- **Never log secrets, passwords, keys, user content** (e.g. message bodies).
- Action enums with secret associated values **must** conform `CustomStringConvertible` (log only case name).
- Matrix IDs safe to log.
### Strings & Localisation
- Default localisation: `en` (en-GB strings), shared with Element X Android via [Localazy](https://localazy.com/p/element).
- **Never edit `Localizable.strings`** — auto-overwritten.
- New English strings → **`Untranslated.strings`** (plurals: `Untranslated.stringsdict`). Team imports to Localazy before merge.
- Access strings via generated `L10n` types (e.g. `L10n.actionDone`).
- **Key naming** (see [element-x-android README](https://github.com/element-hq/element-x-android/blob/develop/tools/localazy/README.md#key-naming-rules)):
- Cross-screen verbs: `action_`. Nouns/other: `common_`. Accessibility: `a11y_`.
- Key match string, e.g. `action_copy_link` → `Copy link`.
- Screen-specific: `screen_<name>_<free>` (e.g. `screen_onboarding_welcome_title`).
- Errors: `error_` prefix or `_error_` infix.
- iOS-only: `_ios` suffix. Android-only: `_android` suffix.
- Placeholders: always numbered form `%1$@`, `%1$d`. Use `%x$@` in iOS source; add translator comment `Localazy: change %x$@ -> %x$s`.
### Previews
- Previews for **all main states**.
- Use `PreviewProvider` (not `#Preview`) — snapshot/accessibility tests generated from it.
- Add `TestablePreview` conformance → snapshot + accessibility tests generated.
---
## Pull Request Guidelines
- New branch from `develop` must not track `develop`.
- Sentence-style titles (no conventional commits).
- Exactly one `pr-` label (see `.github/release.yml`).
- Title = changelog entry — descriptive, no "Fixes #…".
- Leave description template for developer. Point them to [contributing etiquette](CONTRIBUTING.md#etiquette).
- Only fill template LLM line: tick box, name model + harness that made PR (e.g. Claude Opus 5.5, Claude Code).
- Screenshots/videos for visual changes.
- 500 additions max — split big changes.
- Commits need title + description. No tiny commits, no massive commits.
- No history rewrites.
---
## Releasing
HealthChat ship via tag `healthchat-v*` → TestFlight. Full runbook in
`.agents/skills/releasing-healthchat/SKILL.md`. `release/healthchat` = upstream tag +
HealthChat commits on top (patch stack). Upstream sync rebase stack, force-push branch —
only rewrite allowed; procedure in
`.agents/skills/syncing-upstream-healthchat/SKILL.md`. Read skill before release, cert
renewal, or touching `fastlane/`. No duplicate runbook here.
---
## Project Structure
### Build System
Initial setup: `swift run tools setup-project`
**Git hooks** installed by `swift run tools setup-project`, run SwiftLint/SwiftFormat on commit. Hook fail? **Do not abandon changes** — fix reported issues, recommit.
| Tool | Command | Notes |
|------|---------|-------|
| **XcodeGen** | `xcodegen` | Generates Xcode project from `project.yml` (includes `app.yml` + `target.yml` files) |
| **Sourcery** | `sourcery --config Tools/Sourcery/<config>` | Configs: `AutoMockableConfig.yml`, `PreviewTestsConfig.yml`, `TestablePreviewsDictionary.yml`, `AccessibilityTests.yml`. Auto-runs on ElementX build. |
| **SwiftGen** | `swiftgen config run --config Tools/SwiftGen/swiftgen-config.yml` | Auto-runs on ElementX build. |
| **SwiftLint** | `swiftlint` | Auto-runs on ElementX build |
| **SwiftFormat** | `swiftformat .` | Run from project root only. Auto-runs in lint mode on ElementX build. |
CI test commands:
- Unit tests: `swift run tools ci unit-tests`
- CI help: `swift run tools ci --help`
### Targets & Layout
Key targets (each has `target.yml`):
- **ElementX** — main app
- **NSE** — Notification Service Extension
- **ShareExtension** — Share Extension
Each target: `Sources/`, `Resources/`, `SupportingFiles/`.
```
ElementX/Sources/
├── Application/ # App lifecycle, settings, windowing, root coordinators
├── FlowCoordinators/ # Flow coordinators + state machines
├── Services/<Feature>/ # SDK proxies, app services, non-view logic
├── Screens/<Screen>/ # View, ViewModel, Coordinator, Models per screen
├── Other/ # Shared extensions, utilities, SwiftUI helpers
└── {Unit|UI|A11y}Tests/ # Testing infrastructure
```
Mocks, test helpers, generated files live next to sources in `Generated/` directories. Test suites in dedicated targets.
### Swift Packages
1. **`compound-ios/`** — Compound design system (local package, all UI styling).
2. **`Tools/Sources/`** — Developer CLI helpers.
### Dependencies
- **matrix-rust-sdk** source: [`matrix-org/matrix-rust-sdk`](https://github.com/matrix-org/matrix-rust-sdk).
- Binary builds (xcframework + Swift bindings): [`element-hq/matrix-rust-components-swift`](https://github.com/element-hq/matrix-rust-components-swift), imported via `project.yml`.
- SDK hash for build: cross-reference components version in `project.yml` with its tag in components repo. Hash in commit message.
---
## Architecture: MVVM-C
Every screen = **MVVM-Coordinator**. Template: `Tools/Scripts/Templates/SimpleScreenExample/` + `createScreen.sh`.
### Files Per Screen (`Foo`)
| File | Purpose |
|------|---------|
| `FooScreenModels.swift` | `ViewState`, `ViewStateBindings`, `ViewAction` enum, `ViewModelAction` enum |
| `FooScreenViewModelProtocol.swift` | Protocol: `actionsPublisher`, `context` |
| `FooScreenViewModel.swift` | Concrete VM subclassing `StateStoreViewModelV2` |
| `FooScreen.swift` (in `View/`) | SwiftUI view taking `@Bindable var context` |
| `FooScreenCoordinator.swift` | Owns VM, subscribes to actions, exposes own `actionsPublisher` |
| `FooScreenViewModelTests.swift` | Unit tests (UnitTests target) |
### Data Flow
```
View ──send(viewAction:)──► ViewModel ──actionsPublisher──► Coordinator ──actionsPublisher──► FlowCoordinator
◄──viewState────────── │
◄──$context.bindings──► StateMachine<State,Event>
```
### StateStoreViewModelV2
Lives at `ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModelV2.swift` (uses Swift `Observation`):
- `state` — mutable state struct conforming `BindableState`
- `context` — `@Observable` class passed to view:
- `context.viewState` — read-only state
- `context.send(viewAction:)` — sends action to view model
- `$context.<binding>` — two-way SwiftUI bindings
- `context.mediaProvider` — optional media service
- Override `process(viewAction:)` for incoming actions.
- Use `PassthroughSubject<ViewModelAction, Never>` to notify coordinator.
> Some screens still use older `StateStoreViewModel.swift` (Combine/`ObservableObject`).
### Screen Coordinator
```swift
final class FooScreenCoordinator: CoordinatorProtocol {
private let parameters: FooScreenCoordinatorParameters
private let viewModel: FooScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<FooScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<FooScreenCoordinatorAction, Never> { actionsSubject.eraseToAnyPublisher() }
init(parameters: FooScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = FooScreenViewModel(/* dependencies */)
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in /* map to coordinator actions */ }
.store(in: &cancellables)
}
func toPresentable() -> AnyView { AnyView(FooScreen(context: viewModel.context)) }
}
```
### Error Presentation
1. **SwiftUI Alerts** — add `AlertInfo` to `bindings`. Present with one-liner in view.
2. **User Indicators** (via `UserIndicatorController`):
- `.toast` — pill at top of screen (errors)
- `.modal` — not for errors (or actual modal). Blocking overlay for waiting.
---
## Flow Coordinators & Navigation
### FlowCoordinatorProtocol
```swift
@MainActor protocol FlowCoordinatorProtocol {
func start(animated: Bool)
func handleAppRoute(_ appRoute: AppRoute, animated: Bool)
func clearRoute(animated: Bool)
}
```
### State Machines
Uses `StateMachine<State, Event>` from [`ReactKit/SwiftState`](https://github.com/ReactKit/SwiftState):
- `State`/`Event` enums defined inside flow coordinator, conforming `StateType`/`EventType`.
- Simple transitions: `addRoutes(event:transitions:)`. Associated values: `addRouteMapping`.
- `addErrorHandler` should `fatalError` on unexpected transitions.
### Navigation Coordinators
| Type | SwiftUI Equivalent | Usage |
|------|--------------------|-------|
| `NavigationStackCoordinator` | `NavigationStack` | Push/pop within flow |
| `NavigationSplitCoordinator` | `NavigationSplitView` | iPad split view |
| `NavigationTabCoordinator` | `TabView` | Tab navigation |
| `NavigationRootCoordinator` | Root view | Switch app root (e.g. auth → session) |
### CommonFlowParameters
Shared dependency bag for **flow coordinators only**. Never pass to screen coordinators or view models — they get specific dependencies via their `Parameters` struct.
### AppRoute (Deep Linking)
`AppRoute` enum = deep-link destinations. Handled in `handleAppRoute` by rebuilding coordinators, clearing stack, or no-op.
---
## The Rust SDK Layer
### Proxy Pattern (uniffi::Object)
| Layer | Example | Location |
|-------|---------|---------|
| Protocol | `ClientProxyProtocol` | defines interface |
| Proxy | `ClientProxy` | wraps SDK type, in `Services/<Feature>/` |
| Mock | `ClientProxyMock` | Sourcery-generated |
Naming: SDK type name + `Proxy` suffix (e.g. `Client` → `ClientProxy`). Exceptions where specialisation needed (e.g. `JoinedRoomProxy`, `InvitedRoomProxy`).
### Type Mapping (uniffi::Record/Enum)
Map SDK types to app-owned Swift types (no `MatrixRustSDK` import in views):
- `init(rustValue:)` — from SDK
- `var rustValue` — back to SDK
### Wrapping Guidelines
- `Result<T, E>` with typed errors (no bare `throws`).
- `async` only when Rust method async.
- Prefer computed `var` over methods for simple properties.
- Map FFI types: `String` → `URL`, timestamp `UInt64` → `Date`, etc.
- Follow Swift API naming (`userID` not `userId`).
---
## Services
Live in `ElementX/Sources/Services/<Feature>/`.
1. **Pure app services** (e.g. `AppLockService`) — no SDK involvement.
2. **SDK-wrapping services** — compose proxies with app logic. Keep view models simple/testable.
Services = where product-level opinions live (Rust SDK stays spec-faithful).
### Dependency Injection
- Inject via `init` parameters.
- Screen coordinators get `Parameters` struct with specific dependencies.
- `CommonFlowParameters` flow-coordinator-only.
- `UserSession` = per-account dependency container (`clientProxy`, `userSettings`, `mediaProvider`…).
- Need 2+ from it? Pass `userSession` down. Need 1? Pass that one direct.
- Receiver may store only pieces it need (e.g. `clientProxy`, `userSettings`).
- Never pass `userSession` plus value it already owns (e.g. `userSession` + `userSettings`).
---
## Concurrency & Actors
- All targets **Swift 6** language mode (`SWIFT_VERSION: 6`), approachable concurrency. Compiler version come from Xcode toolchain.
- **ElementX** app, **UnitTests** + **PreviewTests**: `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` — no redundant `@MainActor`. Other targets (incl. app extensions) nonisolated.
- Services + data-layer types: `nonisolated` + `Sendable` when background work need.
- Never `@unchecked Sendable` / `nonisolated(unsafe)`. Dev add these.
- `actor` types rare in codebase.
---
## Compound Design System
`compound-ios/` provides **all** UI styling. Use Compound. Deviate only when no equivalent exists.
### Tokens (`.compound` namespace)
- **Colours:** `Color.compound.textPrimary`, `.bgCanvasDefault`, …
- **Fonts:** `Font.compound.bodyLG`, `.headingMDBold`, `.bodySMSemibold`, …
- **Icons:** key paths on `CompoundIcons` (e.g. `\.userProfile`). Always use `CompoundIcon(\.iconName)` (handles Dynamic Type scaling).
### Key Components
| Component | Notes |
|-----------|-------|
| `ListRow` | Primary list/form building block. Label styles: `.default`, `.plain`, `.action`, `.centeredAction`. Kinds: `.label`, `.button`, `.textField`, `.toggle`, etc. |
| `.compoundList()` | Styles `Form`/`List` with Compound tokens |
| `.compoundListSectionHeader/Footer()` | Section header/footer styling |
| `CompoundButtonStyle` | Styles: `.primary`, `.secondary`, `.tertiary`, `.super`, `.textLink`. Sizes: `.large`, `.medium`, `.small`, `.toolbarIcon` |
| `CompoundToggleStyle` | `.toggleStyle(.compound)` |
| `CompoundIcon` | Sizes: `.xSmall` (16pt), `.small` (20pt), `.medium` (24pt), `.custom(CGFloat)` |
| `SendButton` | Specialised send button for message composition |
| `Label` with icon keypaths | `Label("Title", icon: \.userProfile)` uses `CompoundIcon` internally |
### Example
```swift
Form {
Section {
ListRow(label: .default(title: "Setting", icon: \.settings),
kind: .navigationLink { /* action */ })
ListRow(label: .default(title: "Toggle", icon: \.notifications),
details: .isWaiting(context.viewState.isLoading),
kind: .toggle($context.isEnabled))
}
}
.compoundList()
.navigationTitle("Settings")
```
See `compound-ios/Sources/Compound/` for full component set.
---
## Testing
Coverage target: **80%** (includes SDK, Compound, Rich Text Editor).
Project migrating from XCTest to **Swift Testing** (see PR #5119).
### Test Types
| Type | Location | Purpose |
|------|----------|---------|
| Unit Tests | `UnitTests/Sources/` | VM logic, state machines, services |
| UI Tests | `UITests/Sources/` | Flow coordinator integration (snapshots) |
| Preview Tests | `PreviewTests/Sources/` | Auto-generated snapshots from previews |
| Accessibility Tests | `AccessibilityTests/Sources/` | Auto-generated Xcode Accessibility Audits |
No `.serialized` suite traits needed — tests not run in parallel.
### Mocks
Generated by Sourcery (configs in `Tools/Sourcery/`). Files in `Generated/` directories.
- Un-configured methods crash on purpose.
- Common mocks have `Configuration`-based convenience `init`:
```swift
let mock = ClientProxyMock(.init(userID: "@alice:example.com"))
```
### Async Testing
```swift
let deferred = deferFulfillment(context.observe(\.viewState.counter)) { $0 == 1 }
context.send(viewAction: .incrementCounter)
try await deferred.fulfill()
#expect(context.viewState.counter == 1)
```
Same pattern for publishers.
### Snapshots
- Stored in `<Target>/Sources/__Snapshots__/`, tracked via **Git LFS** (`git lfs install`).
- Re-record on correct device/OS if UI changes.
- Powered by [`pointfreeco/swift-snapshot-testing`](https://github.com/pointfreeco/swift-snapshot-testing). Failures produce 3 images: reference, failure, diff.
---
## Xcode Agent Skills
Apple ship agent skills with Xcode 27. Installed per machine, not in repo. Skill missing? Suggest user export them (Xcode running): `xcrun agent skills export --output-dir <agent skills dir>` (Claude Code: `~/.claude/skills`). User decline? Skip skill, carry on.
Skill clash this file? This file win. Build settings, entitlements, Info.plist keys, targets → `project.yml`/`target.yml` + `xcodegen`, never `.xcodeproj` or Xcode project-write tools (`UpdateTargetBuildSetting`, `AddEntitlement`…). API newer than target's deployment target → `#available`.
| Skill | Best moment |
|-------|-------------|
| `swiftui-specialist` | Writing/reviewing views: data flow, `ForEach`/`List` identity, environment, perf. `L10n` + Compound tokens beat its samples. |
| `device-interaction` | Runtime check of UI/flow change (subagent skill). Xcode must open this checkout. No login: env `UI_TESTS_SCREEN=<UITestsScreenIdentifier case>` launch that case's mock fixture, no real session. `roomLayout*`, `appLockFlow*`, `*Pagination` need UITests runner. |
| `swiftui-whats-new-27` | SDK 27 `@State`/`@ContentBuilder` compile errors, or adopting iOS 27 SwiftUI APIs. Keep `AlertInfo` alerts. |
| `modernize-tests` | Modernizing UnitTests suites (`@Test(arguments:)`, `withKnownIssue`, `#require`). XCUI targets stay XCTest. Keep `deferFulfillment`/`waitForConfirmation` — bare `confirmation()` no wait async. No `.serialized`/`@MainActor` adds. |
| `app-intents-specialist`, `app-intents-whats-new-27` | Adding App Intents/Shortcuts/Siri/controls (full support planned; iOS 26+ APIs → `-whats-new-27`). Not SiriKit `IN*` code. Intent strings need `LocalizedStringResource` — `L10n` return `String`. |
---
## Key Files
| File | Purpose |
|------|---------|
| `project.yml` | XcodeGen project (targets, packages, settings) |
| `app.yml` | App-level XcodeGen config |
| `.swiftlint.yml` | SwiftLint rules |
| `.swiftformat` | SwiftFormat rules |
| `.github/workflows/pr-checks.yml` | PR metadata checks (title, labels, size, screenshots) |
| `Package.swift` | SPM manifest (Tools CLI) |
| `localazy.json` | Localazy translation config |
| `codecov.yml` | Codecov config |
| `.periphery.yml` | Periphery dead-code detection |