Documents the release process this fork actually uses: tagging, the numeric-only version rule, the five required secrets, the runner's hand-installed toolchain, certificate renewal, and promoting a TestFlight build to the App Store. Modelled on the legacy element-ios skill, corrected wherever this fork differs — notably that there is no Xcode pin and no Brewfile, and that only five secrets exist rather than the six the legacy skill lists. The section with no upstream equivalent is the migration contract. CLASSIC_APP_* and the .requiresBackup fall-through are load-bearing for every existing user and fail silently, so a well-meaning cleanup back to upstream's values would sign everyone out with nothing in CI to catch it. AGENTS.md delegates to the skill rather than duplicating the runbook.
16 KiB
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 everywhere, Rust SDK wrappers too (
IDnotId,URLnotUrl,configurationnotconfigorcfg). - File headers live in
IDETemplateMacros.plist. - Import
SwiftUI, notUIKit— needUIPasteboardetc,SwiftUIbring it.SwiftUIre-exportFoundationtoo, so noimport Foundationbeside 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.Viewtypes: properties →init→ views (bodyfirst) → 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/.debugrare. - 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. - Never edit
Localizable.strings— auto-overwritten. - New English strings →
Untranslated.strings(plurals:Untranslated.stringsdict). Team imports to Localazy before merge. - Access strings via generated
L10ntypes (e.g.L10n.actionDone). - Key naming (see element-x-android README):
- 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:
_iossuffix. Android-only:_androidsuffix. - Placeholders: always numbered form
%1$@,%1$d. Use%x$@in iOS source; add translator commentLocalazy: change %x$@ -> %x$s.
- Cross-screen verbs:
Previews
- Previews for all main states.
- Use
PreviewProvider(not#Preview) — snapshot/accessibility tests generated from it. - Add
TestablePreviewconformance → snapshot + accessibility tests generated.
Pull Request Guidelines
- New branch from
developmust not trackdevelop. - 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.
- 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. 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
compound-ios/— Compound design system (local package, all UI styling).Tools/Sources/— Developer CLI helpers.
Dependencies
- matrix-rust-sdk source:
matrix-org/matrix-rust-sdk. - Binary builds (xcframework + Swift bindings):
element-hq/matrix-rust-components-swift, imported viaproject.yml. - SDK hash for build: cross-reference components version in
project.ymlwith 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 conformingBindableStatecontext—@Observableclass passed to view:context.viewState— read-only statecontext.send(viewAction:)— sends action to view model$context.<binding>— two-way SwiftUI bindingscontext.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
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
- SwiftUI Alerts — add
AlertInfotobindings. Present with one-liner in view. - 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
@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:
State/Eventenums defined inside flow coordinator, conformingStateType/EventType.- Simple transitions:
addRoutes(event:transitions:). Associated values:addRouteMapping. addErrorHandlershouldfatalErroron 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 SDKvar rustValue— back to SDK
Wrapping Guidelines
Result<T, E>with typed errors (no barethrows).asynconly when Rust method async.- Prefer computed
varover methods for simple properties. - Map FFI types:
String→URL, timestampUInt64→Date, etc. - Follow Swift API naming (
userIDnotuserId).
Services
Live in ElementX/Sources/Services/<Feature>/.
- Pure app services (e.g.
AppLockService) — no SDK involvement. - 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
initparameters. - Screen coordinators get
Parametersstruct with specific dependencies. CommonFlowParametersflow-coordinator-only.ServiceLocatordeprecated — never use services directly. Always inject from above.
Concurrency & Actors
- All targets Swift 6.2, approachable concurrency.
- ElementX app, UnitTests + PreviewTests:
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor— no redundant@MainActor. Other targets (incl. app extensions) nonisolated. - Services + data-layer types:
nonisolated+Sendablewhen background work need. - Never
@unchecked Sendable/nonisolated(unsafe). Dev add these. actortypes 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 useCompoundIcon(\.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
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 convenienceinit:let mock = ClientProxyMock(.init(userID: "@alice:example.com"))
Async Testing
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. Failures produce 3 images: reference, failure, diff.
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 |