Phases 0 to 4 are on this branch and verified on linux; 5 needs two decisions and an asset, 6 and 7 need a mac. Each phase's text is left as it was planned, with a Landed note where what happened is worth knowing. Three findings the plan did not predict. The three library bumps touch no Kotlin source anywhere in the chain -- eleven build scripts in secp256k1-kmp and nothing else -- so gradle reports jvmTest UP-TO-DATE afterwards, which reads like a stale cache and is not one. That bump also carries four fixes beyond the stub archives, the only consequential one being compileSdk 35 -> 36 in secp256k1-kmp's jni/android, below Mantra's own 37. And two of the 84 test names needed rewording by hand, where the comma had been carrying real work rather than pausing. Also records that `-Xdisable-phases` is in but unproven -- no KotlinNativeLink task exists here for it to match -- and the team id Phase 5 is expected to use, which still wants confirming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
26 KiB
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, 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.
Where this stands
Phases 0 to 4 are done and on this branch, verified on a linux host; Phase 5 needs two decisions and an asset, and Phases 6 and 7 need a mac. Nothing below was rewritten after the fact — each phase's text is as it was planned, with a Landed note where what happened is worth knowing.
| phase | state | commit |
|---|---|---|
| 0 bootstrap and baseline | done — 1174 + 159 tests green | (no commit) |
| 1 advance the library chain | done — linux unchanged, as predicted | a8f0e26f, 1b8df6b4, dda2f95c |
| 2 Room constructor and cancellation | done | 73b2c8ae, fc195832 |
| 3 unbreak the ios link tasks | done — linux half only | d024838b |
| 4 ktor on Darwin | done — android and jvm halves only | e6c3c8a2 |
| 5 signing, icon, version | not started — needs decisions | — |
| 6 what only a mac can find | not started — needs a mac | — |
| 7 write down what it cost | in progress — this section | — |
:composeApp:check passes at the tip: lint, the m3 audit's ten counts, and all
three test suites — jvmTest 1174, testDebugUnitTest 563, the library's own 159.
Every count is the Phase 0 baseline, unmoved.
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),
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:
-
The chain will not configure on a mac. secp256k1-kmp's
.defdeclaresstaticLibrariesfor 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 withcinterop file ... does not exist. The fix —9bcae25in secp256k1-kmp, hardened by8adf691— stubs those archives underif (!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. -
Room cannot construct the database on native.
MantraDatabaseConstructoralready exists incommonMain,expect,@Suppressed and referenced by nothing. Without@ConstructedBynaming it, Room's KSP generates noactual, and on a target with no reflection there is no other way in. -
Swift cannot see a cancellation as a cancellation. Six
@Throwssites on the publish path nameNostrPublishExceptionand notkotlin.coroutines.cancellation.CancellationException, so a cancelled publish crosses the ObjC boundary as an unexpected throw. -
The iOS link tasks fail, twice over.
linkDebugTestIosArm64is rejected by Kotlin/Native over backticked test names containing a comma (Name contains illegal characters: ','), andlinkReleaseFrameworkIosArm64crashes Kotlin 2.4.10's release backend insideStaticInitializersOptimization. -
Nothing secure connects on a device. CIO has no TLS implementation on Kotlin/Native — every
wss://andhttps://call dies at the handshake withTLS 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:
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 <level> rev-parse HEAD per
level rather than git submodule status --recursive, which lies after a
per-level url override.
Then record the baseline:
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.
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:
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.
Landed as a8f0e26f, 1b8df6b4, dda2f95c. All five levels now sit exactly
where Curated's do: e0d2b82 → e5b22f5d → 9e3c4f8 → 8adf691 → 7a35fde1.
Two things worth knowing. First, the walk across all three bumps touches no
Kotlin source anywhere in the chain — the entire payload is eleven
build.gradle.kts and settings.gradle.kts files in secp256k1-kmp — so gradle
correctly reports jvmTest UP-TO-DATE afterwards. That reads like a stale cache
and is not one; a forced --rerun returns the same 1174 and 159.
Second, the bump carries four fixes beyond the stub archives, because
a8cc3ec..8adf691 is seven commits rather than three: the generateHeaders
output paths (which were generated./jni, with a stray dot, and whose temp
directory was being cleared with a delete() that silently does nothing on a
non-empty directory), Gradle 10's removal of the delegated-property DSL, Project
objects passed as dependency notation, and compileSdk 35 → 36 in
secp256k1-kmp's jni/android. The last of those is the only one that can touch an
android build here, and it is below Mantra's own 37.
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's
@ColumnTypeConverters:
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
- RelaysSocketManager.kt:103 and
:118 - NostrPublisherRepository.kt:33,
:55and:66 - EventPublishTransport.kt:31 — new, naming
NostrPublishExceptionas well
Keep the fully-qualified style the surrounding annotations already use.
One unused import. import kotlin.native.Platform in
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
Landed as two commits rather than one — 73b2c8ae for @ConstructedBy alone,
fc195832 for the seven annotations and the import — because the first was the
only change in the plan with real linux-side risk and deserved to be readable on
its own.
The risk was real and benign. Room's KSP now emits an actual object MantraDatabaseConstructor for android and jvm as well, each returning
MantraDatabase_Impl(), where before it emitted none at all and the expect sat
@Suppressed and unreferenced. jvmTest is unmoved at 1174. A side effect worth
noting: MantraDatabaseJvmTest's comment about the no-Context builder overload
being available "because MantraDatabaseConstructor gives Room a way to
instantiate without reflection" was aspirational when written and is now true.
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:
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:
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink>().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<KotlinNativeLink>() 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
Landed as d024838b, the linux-verifiable half. The sweep ran exactly as
predicted: 84 names in 35 files, 84 changed lines, every one of them a fun line
and no other line in the tree touched. Both renamed tests show up in the results
under their new names, under jvmTest and testDebugUnitTest alike, and the 138
commas left in the results are jvmTest's own — the set the plan leaves alone.
Two of the 84 needed rewording by hand, where the comma had been carrying real
work rather than pausing: EOSE does not end a live subscription, CLOSED does
became a live subscription ends on CLOSED and not on EOSE, and a page says which chronicle it is, where in it, and who for became ... and where in it and who for. The other 82 lost only a pause.
The -Xdisable-phases block is in but unproven — no KotlinNativeLink task
exists on this host for it to match, so all that is verified is that the build
script still compiles. Whether this module's call graph crashes the same phase
Curated's did is Phase 6's question.
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:
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. It goes inside
the gate, which is why Linux resolution is untouched by this phase.
Three new files, an expect and two actuals, in
press.mantra.compose.network:
commonMain/.../PlatformHttpClientEngine.kt—internal expect fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*>androidMain/.../PlatformHttpClientEngine.android.kt—= CIOjvmMain/.../PlatformHttpClientEngine.jvm.kt—= CIOiosMain/.../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:
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 actuals 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
Landed as e6c3c8a2. The android and jvm actuals and both call sites
compile; the Darwin actual and the thing the phase is actually for do not, here.
ktor-client-darwin 3.5.2 has never been resolved on this host — the gradle cache
holds 3.3.3 and 3.4.1 from older work and nothing newer — because the dependency
sits inside the isMacOsX gate. It resolves on the mac at the same ktor version
ref Curated built against.
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
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 signs
builds to a particular account. The expectation is that Mantra uses the same one
Curare does, ZK3TQ662GB; that still wants confirming before it is written,
since it decides whose account the build belongs to and the bundle identifier is
derived from it.
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:
./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.ktis the newestiosMainactual and the one behind chapter uploads. It exists, so it compiles in principle; whetherUIDocumentPickeractually returns bytes on a device is untested anywhere.- Anything reached only from the new screens — the
StaticInitializerscrash in Phase 3 is call-graph-shaped, and these four lines are what widened the graph. kotlinx.datetimeandokioedges 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, 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 actuals, 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.