Audited all 143 plan files across the 10 plans/ folders. Each plan now carries a Status header (shipped | in-progress | queued | abandoned) backed by codebase evidence, and every folder has a README.md index grouping plans by status. Shipped plans were moved into a per-folder plans/archive/ (via git mv, history preserved) so each plans/ folder surfaces only live work: shipped (archived): 122 in-progress: 8 queued: 7 abandoned: 4 docs/plans/ is the frozen legacy folder; its plans were stamped and indexed in place (48 of 52 archived) but it remains closed to new plans. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
18 KiB
iOS Support for Amethyst
Status: in-progress —
iosArm64/iosSimulatorArm64targets are configured inquartzandcommons(Phase 1), but noiosAppmodule exists yet — later phases not built. Audited 2026-06-30.
Date: 2026-05-24 Status: Phase 1 complete; Phase 2 in flight Owner: TBD
Plan to incrementally bring Amethyst to iOS by extending the existing KMP layers from the bottom up. Each phase is independently shippable — we can pause between any two phases without leaving the tree in a broken state.
Why this is tractable today
The structural work that usually dooms a KMP-to-iOS effort is already done:
quartz/hasiosArm64+iosSimulatorArm64targets configured, a workingPlatform.ios.ktactual, and 6 iOS test files that pass.- Jackson and OkHttp — the two big JVM-only dependencies — are already
isolated to
jvmAndroidin quartz (quartz/src/jvmAndroid/.../jackson/,quartz/src/jvmAndroid/.../okhttp/).commonMainis JVM-free except for the obviouskotlinx.*stack. commons/commonMainhas exactly one Jackson reference (FeedDefinitionSerializer.kt) and zero OkHttp references. The rest of the JVM stickiness lives injvmAndroid/jvmMain/androidMain, which is where it belongs.- Compose Multiplatform 1.10.3 is in use, which supports iOS officially.
secp256k1-kmpships iOS targets.androidx.collection(LruCache) andandroidx.lifecycle.viewmodel.composeare KMP since 2.8.
What this means: we are not embarking on a months-long "purify commonMain" migration before any iOS code can compile. Phase 1 is mostly add iOS to CI and patch the last few leaks.
Module-by-module dep matrix
Status legend:
- ✅ iOS-ready (targets configured, no JVM-only deps in shared code)
- 🟡 Partial (intermediate source sets need adding, but no major dep blockers)
- 🔴 Blocked (significant native work required)
- ⛔ Out of scope (won't ship on iOS)
| Module | Today | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 |
|---|---|---|---|---|---|---|
quartz/ |
✅ | CI + audit | — | — | — | — |
commons/ (non-UI) |
🟡 | — | ✅ | — | — | — |
commons/ (UI) |
🟡 | — | — | ✅ | — | — |
iosApp/ (new) |
n/a | — | — | scaffold | feature-complete | — |
quic/ |
🔴 | — | — | — | — | iOS actuals |
nestsClient/ |
🔴 | — | — | — | — | iOS actuals |
amethyst/ (app) |
⛔ | — | — | — | — | — |
desktopApp/ |
⛔ | — | — | — | — | — |
cli/ |
⛔ | — | — | — | — | — |
Source-set diagram (target end state)
commons/src/
├── commonMain/ ── all targets
│ ├── coreMain/ ── ViewModels, state, DAL (no Compose)
│ │ ├── jvmAndroidCore/ ── Android + Desktop
│ │ │ ├── androidCore/
│ │ │ └── jvmCore/
│ │ └── nativeCore/ ── iOS
│ │ ├── iosArm64Core/
│ │ └── iosSimArm64Core/
│ └── uiMain/ ── Compose UI, icons, resources
│ ├── jvmAndroidUi/
│ │ ├── androidUi/
│ │ └── jvmUi/
│ └── nativeUi/ ── iOS Compose
(Names sketched for clarity; in practice we'll fold coreMain /
uiMain together once every file in uiMain compiles for iOS —
the split is a transitional scaffold for Phase 2 ↔ Phase 3.)
quartz/, quic/, nestsClient/ already use a jvmAndroid shared
source set; we'll add a sibling nativeMain (or just iosMain where
that's simpler) when each module turns on iOS.
Phase 1 — Lock down Quartz on iOS
Duration estimate: 1–2 weeks
Deliverable: ./gradlew :quartz:iosSimulatorArm64Test runs in CI on every PR.
Tasks
-
Add iOS to CI for
:quartz.- GitHub Actions macOS runner step:
iosSimulatorArm64Test+iosArm64SourceSetTest(compile only). - This is the single most valuable change in the entire plan — it
prevents anyone from accidentally re-adding a JVM-only import to
commonMain.
- GitHub Actions macOS runner step:
-
Audit the
jvmAndroidboundary.- Confirm everything Jackson/OkHttp-related lives in
jvmAndroid(it does today — keep it that way). - Add a checkstyle / detekt rule, or a simple grep gate in CI, that
fails the build if
com.fasterxml.jacksonorokhttp3shows up incommonMain.
- Confirm everything Jackson/OkHttp-related lives in
-
Validate
secp256k1iOS path.- Make sure
KeyPair,SchnorrSigner, NIP-44 v2 vectors run green oniosSimulatorArm64Test. - The iOS tests already cover NIP-04 / NIP-17 / NIP-19 / NIP-49 — we just need to surface them in CI.
- Make sure
-
Plan the
expect/actualfor iOS HTTP.- Phase 1 only sketches the design; the actual
Ktor-darwinwiring lands in Phase 2 when:commonsneeds it. - Decide: Ktor everywhere, vs OkHttp on JVM/Android + Ktor on iOS. Recommendation: keep OkHttp on JVM/Android (we use OkHttp-specific features in relay reconnect logic) and add an iOS-only Ktor actual.
- Phase 1 only sketches the design; the actual
Risks
- None major. The work here is mostly defensive.
Phase 2 — Bring :commons to iOS, non-UI first
Duration estimate: 2–3 weeks
Deliverable: ./gradlew :commons:iosSimulatorArm64Test compiles every
shared ViewModel and state class.
Tasks
-
Add iOS targets to
commons/build.gradle.kts.iosArm64()+iosSimulatorArm64().- Introduce intermediate source sets
coreMain(all targets) anduiMain(JVM + Android only, for now).
-
Migrate
FeedDefinitionSerializer.ktoff Jackson.- Move to
kotlinx.serialization, OR - Push it down into
jvmAndroidCoreand create anativeCoreactual. Recommendation: migrate. It's one file; one-time cost is small; reduces split-actual surface area forever.
- Move to
-
Add
expect/actualwrappers for JVM-only deps used by ViewModels.Concern JVM/Android iOS actual HTTP client OkHttp Ktor + Ktor-darwinSecure key storage Android Keystore / java-keyring Keychain Services EXIF strip (image upload) commons-imagingImageIO(CGImageSourceCopyPropertiesAtIndex)File I/O paths java.io.FileNSFileManager/okioLogging android.util.Log/ SLF4Jos_logvia cinterop, or plainprintlnto start -
Compile-only iOS for
:commonsViewModels.- At the end of Phase 2 we have ViewModels, account state, LocalCache
wrappers, filter assemblers, and
ComposeSubscriptionManagerbuilding on iOS — but no UI yet. - Smoke test: write a small
commonTestthat constructs anAccount, subscribes to a stub relay, and verifies a follow event lands inLocalCache. Run it on iOS simulator.
- At the end of Phase 2 we have ViewModels, account state, LocalCache
wrappers, filter assemblers, and
Risks
- Ktor migration scope creep. Hold the line: Phase 2 only wraps HTTP
behind
expect. Don't refactor the relay pool. That's a separate PR. - Coroutines dispatcher differences.
Dispatchers.IOdoes not exist on Kotlin/Native by default — code that explicitly references it needs aKmpDispatchers.IOshim. Audit before Phase 2 starts.
Phase 2 audit (2026-05-24): commons/commonMain iOS-blocker inventory
Audit of all 335 .kt files in commons/src/commonMain/. Better than feared
— most files are already KMP-clean. The actual blockers are 21 files
across ~6 distinct concerns. Each row below is a small mergeable PR.
By blocker category:
| Blocker | Files | Fix |
|---|---|---|
java.util.Base64 |
1 (Base64Image.kt) |
kotlin.io.encoding.Base64 (stdlib since 1.8) |
AtomicLong / AtomicInteger |
2 (ChessLobbyState.kt, SigningState.kt) |
kotlinx.atomicfu.atomic |
ConcurrentHashMap |
4 (ChessRelayFetchHelper.kt, ChessEventCollector.kt, ComposeSubscriptionManager.kt, MutableComposeSubscriptionManager.kt) |
androidx.collection.MutableScatterMap (KMP) — synchronization most likely already provided by enclosing scope; audit per file |
SortedSet + ConcurrentSkipListSet |
2 (EventListMatchingFilter.kt, NoteListMatchingFilter.kt) |
Switch to mutableListOf + sort-on-access, or androidx.collection.MutableScatterSet with manual order |
WeakReference |
5 (Channel.kt, Chatroom.kt, MarmotGroupChatroom.kt, UserRelaysCache.kt, +1) |
expect class KmpWeakReference<T> actuals: JVM java.lang.ref.WeakReference; iOS kotlin.native.ref.WeakReference |
BigDecimal |
1 (Note.kt) |
Either KMP bignum lib (com.ionspin:bignum) or move the BigDecimal-using helper to jvmAndroid and stub on iOS |
java.io.File |
1 (MediaContentModels.kt) |
Replace with String path, or okio.Path |
java.net.URI / MalformedURLException |
2 (RichTextParser.kt, UrlInfoItem.kt) |
KMP URL lib (io.ktor:ktor-http) or stay JVM via expect/actual parseUrl() |
java.nio.charset.Charset |
1 (HtmlCharsetParser.kt) |
kotlin.text.Charsets for UTF-8/16; for arbitrary charsets, expect/actual |
:nestsClient project dep |
2 (NestViewModel.kt, ActiveSubscription.kt) |
Move both files to jvmAndroid source set (audio rooms are Phase 5 anyway) |
com.halilibo.richtext.* |
1 (RenderMarkdown.kt) |
Verify iOS artifact; if missing, move to jvmAndroid until Phase 3 markdown decision |
Files that look scary but aren't:
- 183 files import
androidx.compose.*— these all map to JetBrains Compose Multiplatform's iOS artifacts (identical package paths). No work needed. - 7 files import
androidx.lifecycle.*— KMP since 2.8.0. No work needed. - 0 files import
coil3.network.okhttp(Coil network is already isolated). - 0 files import
javax.*orandroid.*directly from commonMain.
Recommended PR order (ascending cost, descending obviousness):
- ✅ Phase 1 complete (gates + iOS CI for quartz, Jackson migration).
- Base64 (1 file, ~2 LOC change). Demonstrates the pattern.
- Atomics (2 files, atomicfu plugin + ~10 LOC).
- ConcurrentHashMap (4 files; needs concurrency audit per file).
- WeakReference (5 files + 1 new expect/actual).
:nestsClientfiles → jvmAndroid (2 files; pure source-set move).RenderMarkdown.kt→ jvmAndroid OR iOS verification (1 file; depends on lib check).- URL parsing (2 files; either ktor-http dep or expect/actual).
- Charsets, BigDecimal, File (3 files; small per-file decisions).
- After ~9 lands: add iOS targets to
:commons, expect failures to be down to ~zero, runcompileKotlinIosSimulatorArm64to confirm. - Then proceed with the original Phase 2 plan items (Ktor for HTTP, SecureKeyStore expect/actual, etc.) for the cross-cutting deps.
Phase 3 — Compose Multiplatform UI on iOS
Duration estimate: 3–4 weeks
Deliverable: A read-only iOS .ipa on TestFlight internal that connects
to relays and renders a feed.
Tasks
-
Flip
uiMainto target = all (including iOS).- Compose Multiplatform 1.10.3 supports iOS. The Material Symbols font and other Compose Resources already work cross-platform.
-
Audit UI deps for iOS.
Dep Status Action jetbrains.compose.*(1.10.3)✅ None androidx.lifecycle.viewmodel.compose2.8+✅ KMP None coil3✅ iOS Swap network fetcher from coil-okhttptocoil-ktoron iOS via source-set splitmarkdown-ui/markdown-ui-material3⚠️ Verify Likely OK on iOS; if not, fall back to commonmark + custom renderer kotlinx-collections-immutable✅ None Material Symbols font ✅ None (already via Compose Resources) -
Create the
iosApp/module.- SwiftUI
App+UIViewControllerRepresentablehostingComposeUIViewController { App() }. - Tab bar (UIKit) for top-level navigation, Compose for each tab's content area. Same split philosophy as Desktop: native shell, shared content.
- Add Xcode project + Gradle Kotlin/Native framework wiring (no
CocoaPods; use the JetBrains-recommended
embedAndSignAppleFrameworkForXcode).
- SwiftUI
-
Ship a "read-only Nostr browser" first cut.
- Profile view, single-feed home, NoteCard rendering, image loading, basic navigation.
- No posting, no DMs, no audio rooms.
- This validates the entire stack — relay client, LocalCache, feed DAL, NoteCard composable, Coil 3, Compose Resources, font rendering — without touching signing.
Risks
- Compose iOS performance on large feeds. Profile early with a realistic
LocalCache(10k+ notes) before locking screen architecture. If recomposition storms appear, lean harder oncompose-stability-diagnosticsandcompose-state-deferred-readsskills. - Touch interactions vs Android conventions. Pull-to-refresh, swipe back, long-press menus all differ on iOS. Some screens may need platform-specific gesture handling.
- Markdown rendering library iOS support. If
markdown-ui-material3doesn't ship iOS artifacts, this is a half-week detour to switch renderers. Verify in week 1 of Phase 3.
Phase 4 — Write paths: signing, posting, settings
Duration estimate: 2–3 weeks Deliverable: Fully read/write iOS client, minus audio rooms.
Tasks
-
Wire
NostrSignerInternalto Keychain.- The signer is already KMP — only the key storage actual needs
adding (done in Phase 2's
SecureKeyStoreabstraction).
- The signer is already KMP — only the key storage actual needs
adding (done in Phase 2's
-
Make
NostrSignerRemote(NIP-46 bunker) work on iOS.- Should be KMP-clean once Ktor migration is done. Audit for any stray Jackson / OkHttp inside the NIP-46 path.
-
NIP-55 alternative.
- There is no Amber on iOS. Plan replacements:
- Push users toward NIP-46 bunkers (Nsec.app, Amber-as-bunker, remote nostr-connect URIs).
- URL-scheme handoff to native iOS signers (
nos2x-fhe,Nostore) if they expose a sign API. Track separately.
- Onboarding screen needs an iOS-specific copy variant.
- There is no Amber on iOS. Plan replacements:
-
Posting, reactions, zaps.
- Mostly free — ViewModels already in
:commons. Wire UI buttons and test end-to-end on TestFlight.
- Mostly free — ViewModels already in
-
Settings UI.
- Share via Compose. iOS-native preference screens are a polish item for later.
Risks
- Apple App Review on cryptocurrency / zaps. Lightning zaps via LNURL are fine (no in-app crypto purchase). Anything that looks like an in-app wallet or onchain send may need legal review and / or feature gating per-region. Start review conversations early.
- Push notifications. APNs is the only path on iOS. Nostr DM push
relays don't speak APNs natively. Likely needs a small relay-proxy
(similar to
notify.damus.io's architecture). Design doc inamethyst/plans/before Phase 4 ends.
Phase 5 — :quic + :nestsClient for audio rooms (optional)
Duration estimate: 4–6 weeks Status: Defer until 1–4 are solid. App is shippable on iOS without audio rooms.
Tasks
-
:quic— add iOS actuals.- UDP socket via
Network.framework(NWConnectionwith.udp). - AEAD (AES-GCM, ChaCha20-Poly1305) via Apple CryptoKit
(
AES.GCM.SealedBox,ChaChaPoly). - TLS state machine is already pure Kotlin in
commonMain— no change.
- UDP socket via
-
:nestsClient— add iOS actuals.- Opus encode/decode:
libopusvia cinterop, or pullopus.frameworkfrom a Swift Package / CocoaPods spec. - Mic + speaker:
AVAudioEngine(input/output nodes) instead ofAudioRecord/AudioTrack.
- Opus encode/decode:
-
moq-lite listener path first (the production path per CLAUDE.md), then speaker.
Risks
- Background audio on iOS. Audio rooms in the background need a
proper
AVAudioSessioncategory + theaudiobackground mode inInfo.plist. Apple sometimes rejects apps that abuse this. Worth a separate audit before submission. - Opus framework distribution.
libopusvia Swift Package is cleanest; CocoaPods is fine but pulls in a build-time dep on Ruby. Decide before Phase 5 starts.
Phase 6 — Ship polish (ongoing, post-Phase 4)
- App Store metadata, screenshots, privacy manifest
(
NSPrivacyAccessedAPI*declarations — file access, user defaults). - Localizations carry over automatically via Compose Resources.
- Background fetch limits — iOS is far stricter than Android. Tune feed prefetch + relay reconnect for background launch budgets.
- TestFlight beta → public release.
Cross-cutting risks (track from day one)
| Risk | Mitigation | First chance to catch |
|---|---|---|
commonMain regresses with a JVM-only import |
Add iOS to CI on every PR | Phase 1, task 1 |
Coroutines Dispatchers.IO ergonomics on iOS |
Audit + introduce KmpDispatchers shim |
Phase 2, task 3 |
| Compose iOS performance on big feeds | Early profiling with realistic LocalCache |
Phase 3 risk section |
| App Store review (zaps, onchain) | Talk to legal / read App Store guidelines early | Phase 4 risk section |
| No NIP-55 equivalent on iOS | Lean on NIP-46; document in onboarding | Phase 4, task 3 |
| Push notifications via APNs | Relay-proxy design doc | Phase 4, end of phase |
| Background audio policy | AVAudioSession audit + Info.plist review |
Phase 5 risk section |
Suggested first PR
Smallest useful start: Phase 1, tasks 1 + 2 — add :quartz iOS to
CI and add the import-gate that prevents Jackson / OkHttp regressions
in commonMain. That single PR de-risks the rest of the plan without
touching any product code.
Open questions
- Do we want a
:clianalogue on iOS (a "headless" Nostr daemon)? Out of scope for this plan, but iosArm64 could host one if we ever need a CLI-on-phone story. - Mac Catalyst vs native macOS: Desktop is already JVM-Compose. We could theoretically also ship Catalyst from the iOS build, but that's three "desktop"-ish targets to maintain. Recommendation: punt.
- iPad layout: do we want a separate split-view UI like
desktopApp, or just scale up the iPhone layout? Phase 3 keeps the iPhone layout; iPad polish is a Phase 6 item.