# Bringing up the iOS target What it takes to build and run Mantra on an iPhone, phased, following the route the Curated fork already walked. Every one of its findings is diagnosed and reproducible here, so the work is porting a known answer rather than re-discovering it. The headline is that almost nothing is missing. The `iosArm64` and `iosSimulatorArm64` targets are **already declared** in [composeApp/build.gradle.kts:36](../composeApp/build.gradle.kts), gated behind `OperatingSystem.current().isMacOsX` the way everything in the composite chain is; `composeApp/src/iosMain` holds ten `actual` files that match `jvmMain`'s ten one-for-one, so no `expect` is unimplemented; `commonMain` has no `java.*` or `javax.*` import anywhere; quartz 1.14.0 publishes `iosArm64` and `iosSimulatorArm64` klibs; and `iosApp/` is byte-identical to Curated's apart from brand tokens and one icon file, Xcode build phase and all. What stands in the way is five concrete defects, which Curated hit in the order a build hits them — configure, compile, link, connect, sign — and fixed in eight commits. ## The eight commits, and what each one is here | Curated | what it did | how it lands here | |---|---|---| | `94de86a3`, `cbf23640`, `64b37447` | three gitlink bumps of `lightning-kmp-app`, carrying a secp256k1-kmp build fix | the same three bumps, verbatim — Mantra is pinned at their exact parent | | `987df2c8` | `@ConstructedBy`, `@Throws(CancellationException)`, an unused import, `TEAM_ID` | the same, minus `TEAM_ID`, which is account-specific | | `aa9312f0` | comma-free `commonTest` names, plus a disabled compiler phase | **rewritten**, not cherry-picked — see Phase 3 | | `b9140c6a` | the Darwin ktor engine | near-verbatim; the shape it assumes is the shape here | | `bd47c841` | app icon and `MARKETING_VERSION` | needs Mantra artwork | | `a673c85e` | `versionName` | a release decision, not a port | ## What is already done for you **The pin lines up exactly.** Mantra records `lightning-kmp-app` at `84cc44c855e3d173e26016b807c449153a3af597`. That is the *parent* of Curated's `94de86a3`. The three bumps are therefore a straight walk along the same gitlinks — `84cc44c` → `2dcd4fb` → `db15e59` → `e0d2b82` — with no rebase and no divergence to reconcile. **The library's own iOS gating is already right.** `lightning-kmp-app/library` declares `iosArm64()` and `iosSimulatorArm64()` under the same `isMacOsX` guard, and sets `kotlin.native.enableKlibsCrossCompilation=true`. Nothing in the outer build has to teach it about apple targets. **`commonMain` is clean, and the publish surface is small.** One `HttpClient {` construction in the whole module ([NostrSocketClientFactory.kt:10](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt)), no explicit Coil `ImageLoader` anywhere, six `@Throws` annotations on the publish path. That is the same shape Curated's fixes were written against, so Phases 2 and 4 are small and exact rather than a search. **The audit does not look at tests.** `docs/scripts/m3-audit.sh` scans `composeApp/src/commonMain/kotlin/press/mantra/compose/ui` only, so Phase 3's sweep over test names cannot trip `./gradlew check`. ## What actually blocks it Five things, independent of each other except for the order the build reveals them in: 1. **The chain will not configure on a mac.** secp256k1-kmp's `.def` declares `staticLibraries` for every target and the cinterop tool refuses to emit a klib while the archive is absent. Off a Linux host the Linux archives are never built, so the IDE sync and configuration fail with `cinterop file ... does not exist`. The fix — `9bcae25` in secp256k1-kmp, hardened by `8adf691` — stubs those archives under `if (!currentOs.isLinux)`. Its commit subject says "linux cinterop", which reads backwards from here: **it is the fix that makes the chain work on a mac**, and it is a strict no-op on Linux. 2. **Room cannot construct the database on native.** `MantraDatabaseConstructor` already exists in `commonMain`, `expect`, `@Suppress`ed and referenced by nothing. Without `@ConstructedBy` naming it, Room's KSP generates no `actual`, and on a target with no reflection there is no other way in. 3. **Swift cannot see a cancellation as a cancellation.** Six `@Throws` sites on the publish path name `NostrPublishException` and not `kotlin.coroutines.cancellation.CancellationException`, so a cancelled publish crosses the ObjC boundary as an unexpected throw. 4. **The iOS link tasks fail, twice over.** `linkDebugTestIosArm64` is rejected by Kotlin/Native over backticked test names containing a comma (`Name contains illegal characters: ','`), and `linkReleaseFrameworkIosArm64` crashes Kotlin 2.4.10's release backend inside `StaticInitializersOptimization`. 5. **Nothing secure connects on a device.** CIO has no TLS implementation on Kotlin/Native — every `wss://` and `https://` call dies at the handshake with `TLS sessions are not supported on Native platform`. The relay sockets and Coil's image fetcher both sit on a default-engine client. Everything below is ordered by that list. --- ## Phase 0 — bootstrap, and a recorded baseline Nothing is changed in this phase. Its output is a green build to compare against, because four of the five defects above are invisible until a mac is in the loop and you do not want to be guessing afterwards which failure you introduced. **In this worktree, initialise the chain first.** Four nested submodules — `lightning-kmp-app` → `experimental/lightning-kmp` → `experimental/bitcoin-kmp` → `experimental/secp256k1-kmp` (→ `native/secp256k1`) — are unpopulated, and gradle dies early and confusingly with `Project with path ':library' not found`: ```bash git -c protocol.file.allow=always submodule update --init --recursive ``` `protocol.file.allow=always` is required: the recorded url for `lightning-kmp-app` is the main checkout's path, and git refuses `file` transport for submodules by default. The last level (`native/secp256k1`, fetched from GitHub) can stall for ten minutes or more; let the recursive call do what it can and finish that one level from the main checkout's copy. Trust `git -C rev-parse HEAD` per level rather than `git submodule status --recursive`, which lies after a per-level url override. Then record the baseline: ```bash ANDROID_HOME=$HOME/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest :lightning-kmp-app:library:jvmTest ``` Note the jvmTest count and that it is green. `IdentitySwitchJvmTest`'s "last profile opened" case fails about one run in nine on a 5s DataStore timeout on a clean tree too — if you see exactly that, re-run before believing it. **Deliverable:** no commit. A noted baseline. --- ## Phase 1 — advance the library chain The three gitlink bumps, kept as three commits so each one says what it carries. Walk the outer pin, and let the nested gitlinks come with it. ```bash git -C lightning-kmp-app fetch https://github.com/kngako/lightning-kmp-app.git 'refs/heads/*:refs/remotes/github/*' git -C lightning-kmp-app checkout --detach 2dcd4fbe1513421d100a4485583f167b1fdcd47c git -c protocol.file.allow=always -C lightning-kmp-app submodule update --init --recursive git add lightning-kmp-app && git commit ``` In a worktree the submodule's `origin` is the **main checkout's copy**, not GitHub, so `git fetch origin` shows nothing new — fetch the `.gitmodules` url directly, as above. Each bump moves `experimental/lightning-kmp` too, which moves `bitcoin-kmp`, which moves `secp256k1-kmp`, so re-run `submodule update --init --recursive` inside `lightning-kmp-app` after each checkout or the chain is left mid-walk. The three, in order: | commit | pin | what it carries | |---|---|---| | 1 | `2dcd4fb` | secp256k1-kmp `9bcae25` — the stub archives that let the chain configure off Linux | | 2 | `db15e59` | the same fix merged into the `threshold` branches, so the nested gitlinks name branch tips rather than side commits | | 3 | `e0d2b82` | secp256k1-kmp `8adf691` — registers the stub task with `tasks.register` instead of the delegate provider, which Gradle 9 requires | Commits 2 and 3 look like bookkeeping and are not: without 3 the stub task is never registered under a name, and without 2 the pins sit on commits that were rebased away. **Verify, on Linux.** The whole fix is behind `if (!currentOs.isLinux)`, so Linux must be *unchanged*: ```bash ANDROID_HOME=$HOME/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest :lightning-kmp-app:library:jvmTest ``` Same test count, same green, as Phase 0. The library's own ~159 tests run inside the composite build and are the thing that proves the wiring still gives it what it expects. **Verify, on a mac.** `./gradlew :composeApp:tasks` configures at all, and `:composeApp:compileKotlinIosArm64` resolves `fr.acinq.phoenix:lightning-kmp-app`. That is the phase's actual purpose. > Suggested message, commit 1: > `build: carry secp256k1-kmp's fix for configuring off a linux host` > — and say in the body that the upstream subject reads the other way round. --- ## Phase 2 — declare the Room constructor and cancellation to Swift Four small edits, one commit. All of it compiles on Linux. **`@ConstructedBy`.** Add the import and the annotation above [MantraDatabase.kt:369](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt)'s `@ColumnTypeConverters`: ```kotlin import androidx.room3.ConstructedBy … @ConstructedBy(MantraDatabaseConstructor::class) @ColumnTypeConverters(MantraConverters::class) abstract class MantraDatabase: RoomDatabase() { ``` The `expect object` it names already exists and needs no edit. This is the one change in the whole plan that **also changes the JVM and Android builds**: Room's KSP starts generating an `actual` for every target rather than none, so the annotation that unblocks iOS is the annotation most likely to move something underneath `:composeApp:jvmTest`. Run it and read the output rather than assuming. **Cancellation on the publish path.** Add `kotlin.coroutines.cancellation.CancellationException::class` to the six existing `@Throws`, and give `EventPublishTransport.publishEvent` one it does not have: - [RelayPool.kt:289](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt) - [RelaysSocketManager.kt:103](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) and `:118` - [NostrPublisherRepository.kt:33](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt), `:55` and `:66` - [EventPublishTransport.kt:31](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt) — new, naming `NostrPublishException` as well Keep the fully-qualified style the surrounding annotations already use. **One unused import.** `import kotlin.native.Platform` in [Platform.ios.kt](../composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt) shadows the app's own `Platform` interface. Delete the line. Nothing on Linux compiles `iosMain`, so this one is carried on Curated's word and the compiler's, later. **Verify, on Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:jvmTest`. **On a mac:** `:composeApp:compileKotlinIosArm64`. > `feat(ios): declare the Room constructor and publish cancellation to swift` --- ## Phase 3 — unbreak the iOS link tasks Two unrelated link failures, one commit, because they are the same symptom from the outside: `./gradlew :composeApp:linkDebugTestIosArm64` and `:composeApp:linkReleaseFrameworkIosArm64` both fail. ### The comma names — rewrite, do not cherry-pick Curated's `aa9312f0` touched 38 `commonTest` files. **Ten of them do not exist here** (`GroupEventProposalTest`, `CuratedSchemaEventTest`, `CuratedEntryEventTest`, `CuratedSchemaTemplateTest`, `GroupCuratedEntryTest`, `GroupCuratedSchemaTest`, `GroupPostTest`, `CuratedSuggestionTest`, `MarmotRoomIdentityTest`, `MarmotPayloadBoundsTest`, `FrostSigningMembersTest`) — they belong to the fork's curated-list and Marmot-v2 lines, neither of which Mantra has pulled. Meanwhile Mantra has test files Curated does not: `ProjectEventTest`, `ChapterProposalSizeTest`, `UploadChaptersStateTest`, `UploadReviewTest`, `SubgroupLineageTest`, `MarkdownBlockTest`, `MarkdownProducerTest`, `ChapterTextTest`. So do the sweep over **Mantra's own tree**: 84 backticked `fun` names across 35 files in `composeApp/src/commonTest`. Every comma in every one of them is followed by exactly one space — verified, no exceptions — so the rewrite is total and unambiguous: ```bash grep -rl 'fun `[^`]*,[^`]*`' composeApp/src/commonTest --include='*.kt' \ | xargs perl -i -pe 's/(fun `[^`]*`)/($x=$1)=~s{, }{ }g; $x/ge' ``` Then read the diff. A name losing its comma loses a little of its rhythm and none of its meaning (`read as its kind, tags and content` → `read as its kind tags and content`); if any of the 84 reads badly afterwards, reword that one by hand rather than leaving a comma behind. **Leave `jvmTest` alone.** It has 138 comma names and Kotlin/Native never compiles it. Curated left it alone for the same reason, and widening the sweep would put 138 cosmetic edits in a commit that is about a link failure. ### The release backend crash Append to `composeApp/build.gradle.kts`, after the `kotlin { }` block and before `android {` at line 161 — the same place Curated put it: ```kotlin tasks.withType().configureEach { if (name.startsWith("linkRelease")) { toolOptions.freeCompilerArgs.add("-Xdisable-phases=RemoveRedundantCallsToStaticInitializersPhase") } } ``` With the comment Curated wrote, which is the valuable part: the pass is an LTO optimization, so only optimized links reach it (which is why the debug test binary links fine); it assumes every function in the call graph has at least one statement in its body; something in this module's graph arrives with an empty one; and disabling it costs the redundant initializer calls it would have removed and nothing else. `tasks.withType()` matches nothing on Linux, so this is inert there — but the class resolves from the KMP plugin's classpath on any host, so the script still has to compile. `:composeApp:compileDebugKotlinAndroid` proves that much. **Expect this one not to be the end of it.** The crash is a property of the call graph, and Mantra's graph is a superset of Curated's — projects, chapter uploads, multi-parent subgroups, the markdown producer. The same phase may crash somewhere else, or a different phase may crash. Treat the stack trace as the instruction: disable the named phase, and write down why. **Verify, on a mac:** `:composeApp:linkDebugTestIosArm64` and `:composeApp:linkReleaseFrameworkIosArm64`. **On Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:jvmTest` — the renamed tests must still run, and the count must not move. > `build(ios): unbreak the ios link tasks` --- ## Phase 4 — put every ktor client on Darwin CIO has no TLS on Kotlin/Native. Pick the engine per platform instead of letting the classpath pick it. **Catalogue entry**, beside `ktor-client-cio` in [gradle/libs.versions.toml:82](../gradle/libs.versions.toml): ```toml ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ``` **Dependency**, inside the existing `isMacOsX`-gated `iosMain.dependencies` block at [composeApp/build.gradle.kts:154](../composeApp/build.gradle.kts). It goes inside the gate, which is why Linux resolution is untouched by this phase. **Three new files**, an `expect` and two `actual`s, in `press.mantra.compose.network`: - `commonMain/.../PlatformHttpClientEngine.kt` — `internal expect fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*>` - `androidMain/.../PlatformHttpClientEngine.android.kt` — `= CIO` - `jvmMain/.../PlatformHttpClientEngine.jvm.kt` — `= CIO` - `iosMain/.../PlatformHttpClientEngine.ios.kt` — `= Darwin` **Two call sites.** `defaultSocketsHttpClient` becomes `HttpClient(platformHttpClientEngineFactory()) { … }`, keeping its `WebSockets` block as-is. And `MantraApp` gains the singleton Coil loader, because Coil's default ktor fetcher builds a bare `HttpClient()` and would fail on `https://` images exactly as the sockets did: ```kotlin private val imageHttpClient by lazy { HttpClient(platformHttpClientEngineFactory()) } @Composable fun MantraApp(…) { setSingletonImageLoaderFactory { context -> ImageLoader.Builder(context) .components { add(KtorNetworkFetcherFactory(httpClient = { imageHttpClient })) } .build() } MantraTheme { … } } ``` `setSingletonImageLoaderFactory` goes *above* `MantraTheme`, at the top of the composable's body, so it is installed before the first `AsyncImage` composes. `MantraApp` is the only place both call sites are reachable from, and Mantra has no `ImageLoader` configuration today to reconcile with. **Verify, on Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinJvm :composeApp:jvmTest` — the android and jvm `actual`s and both call sites compile here, which is most of the phase. **On a mac:** `:composeApp:compileKotlinIosArm64`, then the real test — a device or simulator that reaches a relay over `wss://` and renders an avatar over `https://`. > `fix(ios): build ktor clients on Darwin so wss/https work on a device` --- ## Phase 5 — signing, icon, version Three things Curated's last two commits did that **cannot be ported**, only decided. Each needs an answer from you before it can be written. **`TEAM_ID`.** [iosApp/Configuration/Config.xcconfig](../iosApp/Configuration/Config.xcconfig) has `TEAM_ID=` empty, and the bundle identifier is built from it (`press.mantra.ios$(TEAM_ID)`). Curated committed its development team id in the clear, which is normal for an xcconfig — a team id is not a secret — but it is *their* team id and signs builds to *their* account. Mantra needs its own, or an `xcconfig` that stays out of git. **The app icon.** Curated replaced its single `app-icon-1024.png` with a 1024 light variant and a 1024 dark one, adding the `luminosity: dark` appearance to `Contents.json` and leaving the `tinted` slot filenameless so iOS derives it. Mantra's `AppIcon.appiconset` still holds one 1024 file and one entry. The *structure* ports; the artwork does not. Two 1024×1024 PNGs are needed. **`MARKETING_VERSION`.** Mantra's xcconfig says `1.0` while `android { versionName }` says `0.1.2` — the two have simply never been reconciled because nothing shipped on iOS. Curated set both to the `0.1.x` line. Pick one number for both; the mismatch is the bug, not either value. Curated's `a673c85e` moved `versionName` from `0.1.2` to `0.1.1`, which is a release-bookkeeping step on their tree and means nothing on this one. Do not port it. > `feat(ios): the app icon, the signing team, and one version across both stores` --- ## Phase 6 — the part only a mac can find Phases 1 to 4 fix everything Curated hit. They do not fix what Curated never had, and Mantra has pulled four lines of work the fork does not carry: projects, chapter uploads, multi-parent subgroups, and the markdown producer. None of it has ever been compiled for `iosArm64`. So the honest shape of this phase is a loop, not a list: ```bash ./gradlew :composeApp:compileKotlinIosArm64 ./gradlew :composeApp:linkDebugTestIosArm64 ./gradlew :composeApp:iosSimulatorArm64Test ./gradlew :composeApp:linkReleaseFrameworkIosArm64 ``` then Xcode, whose build phase already runs `:composeApp:embedAndSignAppleFrameworkForXcode` against the `ComposeApp` framework, deployment target 18.2. Known places to look first, each a guess with a reason: - **`FilePicker.ios.kt`** is the newest `iosMain` actual and the one behind chapter uploads. It exists, so it compiles in principle; whether `UIDocumentPicker` actually returns bytes on a device is untested anywhere. - **Anything reached only from the new screens** — the `StaticInitializers` crash in Phase 3 is call-graph-shaped, and these four lines are what widened the graph. - **`kotlinx.datetime` and `okio` edges** in the markdown and chapter paths, which are the usual source of a jvm-only overload that compiles everywhere but native. Each fix found here is its own commit, in the voice of what broke rather than "ios fixes". --- ## Phase 7 — write down what it cost This document, revised from a plan into a record: which of the five defects behaved as predicted, what Phase 6 actually found, and which compiler phase is disabled with which stack trace behind it. Then revise its row in [docs/README.md](./README.md), which this document already carries, so it describes what happened rather than what was planned. The `-Xdisable-phases` flag in particular needs a home in prose. It is a load- bearing workaround for an upstream compiler bug, it is invisible from the source, and the next person to bump Kotlin should be able to find out in one grep whether they can take it out again. --- ## What to do if there is no mac Phases 1 to 4 are worth landing anyway, and all four are verifiable on Linux for the half that Linux compiles: | phase | Linux-verifiable | needs a mac | |---|---|---| | 1 | the fix is `if (!currentOs.isLinux)`, so "unchanged" is the whole check | that it configures at all | | 2 | `@ConstructedBy` and all seven `@Throws` | the unused-import removal | | 3 | the renamed tests still run; the build script still compiles | both link tasks | | 4 | the `expect`, the android and jvm `actual`s, both call sites | the Darwin `actual`, and TLS actually working | | 5 | nothing | nothing — it needs decisions, not a host | Phase 2's `@ConstructedBy` is the one with real Linux-side risk, since it changes what Room's KSP generates for every target. Land it on its own and read `:composeApp:jvmTest` carefully.