diff --git a/docs/README.md b/docs/README.md index f47439a6..50ebb0bc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ silent, or a decision that looked arbitrary and was not. | [npub-sign-in.md](./npub-sign-in.md) | signing in with only a public key — what a key that cannot sign can still see here, why that is a preview rather than a browser, and the four decisions the word settles | | [multiple-profiles.md](./multiple-profiles.md) | several profiles on one device — why a profile is a credential and a seed is a wallet attached to one, why a switch is a restart rather than a swap, the two entrances the app lacks, and the inbox a switch would silently lose | | [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it | +| [ios-build.md](./ios-build.md) | building for an iPhone, following the route the Curated fork walked — the five defects between here and a device, why the fork's "linux cinterop" fix is the one that makes a mac work, and the one annotation that unblocks native by changing every other target | | [npub-profile-preview.md](./npub-profile-preview.md) | showing the person before a direct message is started from a pasted npub — why the preview is a screen keyed by public key, what "found" means when a row can be a placeholder, and the phase that makes the button honest about a key package that never comes | | [chapter-uploads.md](./chapter-uploads.md) | picking files off a device and having the group sign each one in as a chapter — why N files can never be one signing session, and the four things the single-chapter path gets away with | | [project-feature.md](./project-feature.md) | bringing the server schema's `project` and its two join models into a group that has no accounts and no server — a creator tag of its own and owners as a signed set of `p` tags, the role the parser has to be told about or every project lands ownerless, why a membership is its own signed event rather than a list, and the kind band that is already double-booked | diff --git a/docs/ios-build.md b/docs/ios-build.md new file mode 100644 index 00000000..55e56c38 --- /dev/null +++ b/docs/ios-build.md @@ -0,0 +1,462 @@ +# 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.