mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
docs: add iOS support plan
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
# iOS Support for Amethyst
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Status:** Draft — not yet started
|
||||
**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/` has `iosArm64` + `iosSimulatorArm64` targets configured,
|
||||
a working `Platform.ios.kt` actual, and 6 iOS test files that pass.
|
||||
- Jackson and OkHttp — the two big JVM-only dependencies — are *already
|
||||
isolated to `jvmAndroid`* in quartz (`quartz/src/jvmAndroid/.../jackson/`,
|
||||
`quartz/src/jvmAndroid/.../okhttp/`). `commonMain` is JVM-free except
|
||||
for the obvious `kotlinx.*` stack.
|
||||
- `commons/commonMain` has exactly **one** Jackson reference
|
||||
(`FeedDefinitionSerializer.kt`) and zero OkHttp references. The rest
|
||||
of the JVM stickiness lives in `jvmAndroid` / `jvmMain` / `androidMain`,
|
||||
which is where it belongs.
|
||||
- Compose Multiplatform 1.10.3 is in use, which supports iOS officially.
|
||||
- `secp256k1-kmp` ships iOS targets. `androidx.collection` (LruCache) and
|
||||
`androidx.lifecycle.viewmodel.compose` are 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
|
||||
|
||||
1. **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`.
|
||||
|
||||
2. **Audit the `jvmAndroid` boundary.**
|
||||
- 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.jackson` or `okhttp3` shows up
|
||||
in `commonMain`.
|
||||
|
||||
3. **Validate `secp256k1` iOS path.**
|
||||
- Make sure `KeyPair`, `SchnorrSigner`, NIP-44 v2 vectors run green
|
||||
on `iosSimulatorArm64Test`.
|
||||
- The iOS tests already cover NIP-04 / NIP-17 / NIP-19 / NIP-49 — we
|
||||
just need to surface them in CI.
|
||||
|
||||
4. **Plan the `expect`/`actual` for iOS HTTP.**
|
||||
- Phase 1 only sketches the design; the actual `Ktor-darwin` wiring
|
||||
lands in Phase 2 when `:commons` needs 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.
|
||||
|
||||
### 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
|
||||
|
||||
1. **Add iOS targets to `commons/build.gradle.kts`.**
|
||||
- `iosArm64()` + `iosSimulatorArm64()`.
|
||||
- Introduce intermediate source sets `coreMain` (all targets) and
|
||||
`uiMain` (JVM + Android only, for now).
|
||||
|
||||
2. **Migrate `FeedDefinitionSerializer.kt` off Jackson.**
|
||||
- Move to `kotlinx.serialization`, OR
|
||||
- Push it down into `jvmAndroidCore` and create a `nativeCore` actual.
|
||||
**Recommendation:** migrate. It's one file; one-time cost is small;
|
||||
reduces split-actual surface area forever.
|
||||
|
||||
3. **Add `expect`/`actual` wrappers for JVM-only deps used by ViewModels.**
|
||||
|
||||
| Concern | JVM/Android | iOS actual |
|
||||
|---|---|---|
|
||||
| HTTP client | OkHttp | Ktor + `Ktor-darwin` |
|
||||
| Secure key storage | Android Keystore / java-keyring | Keychain Services |
|
||||
| EXIF strip (image upload) | `commons-imaging` | `ImageIO` (`CGImageSourceCopyPropertiesAtIndex`) |
|
||||
| File I/O paths | `java.io.File` | `NSFileManager` / `okio` |
|
||||
| Logging | `android.util.Log` / SLF4J | `os_log` via cinterop, or plain `println` to start |
|
||||
|
||||
4. **Compile-only iOS for `:commons` ViewModels.**
|
||||
- At the end of Phase 2 we have ViewModels, account state, LocalCache
|
||||
wrappers, filter assemblers, and `ComposeSubscriptionManager` building
|
||||
on iOS — but no UI yet.
|
||||
- Smoke test: write a small `commonTest` that constructs an `Account`,
|
||||
subscribes to a stub relay, and verifies a follow event lands in
|
||||
`LocalCache`. Run it on iOS simulator.
|
||||
|
||||
### 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.IO` does not exist on
|
||||
Kotlin/Native by default — code that explicitly references it needs a
|
||||
`KmpDispatchers.IO` shim. Audit before Phase 2 starts.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. **Flip `uiMain` to target = all (including iOS).**
|
||||
- Compose Multiplatform 1.10.3 supports iOS. The Material Symbols font
|
||||
and other Compose Resources already work cross-platform.
|
||||
|
||||
2. **Audit UI deps for iOS.**
|
||||
|
||||
| Dep | Status | Action |
|
||||
|---|---|---|
|
||||
| `jetbrains.compose.*` (1.10.3) | ✅ | None |
|
||||
| `androidx.lifecycle.viewmodel.compose` 2.8+ | ✅ KMP | None |
|
||||
| `coil3` | ✅ iOS | Swap network fetcher from `coil-okhttp` to `coil-ktor` on iOS via source-set split |
|
||||
| `markdown-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) |
|
||||
|
||||
3. **Create the `iosApp/` module.**
|
||||
- SwiftUI `App` + `UIViewControllerRepresentable` hosting
|
||||
`ComposeUIViewController { 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`).
|
||||
|
||||
4. **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 on `compose-stability-diagnostics` and
|
||||
`compose-state-deferred-reads` skills.
|
||||
- **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-material3`
|
||||
doesn'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
|
||||
|
||||
1. **Wire `NostrSignerInternal` to Keychain.**
|
||||
- The signer is already KMP — only the key storage actual needs
|
||||
adding (done in Phase 2's `SecureKeyStore` abstraction).
|
||||
|
||||
2. **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.
|
||||
|
||||
3. **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.
|
||||
|
||||
4. **Posting, reactions, zaps.**
|
||||
- Mostly free — ViewModels already in `:commons`. Wire UI buttons and
|
||||
test end-to-end on TestFlight.
|
||||
|
||||
5. **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 in
|
||||
`amethyst/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
|
||||
|
||||
1. **`:quic` — add iOS actuals.**
|
||||
- UDP socket via `Network.framework` (`NWConnection` with `.udp`).
|
||||
- AEAD (AES-GCM, ChaCha20-Poly1305) via Apple CryptoKit
|
||||
(`AES.GCM.SealedBox`, `ChaChaPoly`).
|
||||
- TLS state machine is already pure Kotlin in `commonMain` — no change.
|
||||
|
||||
2. **`:nestsClient` — add iOS actuals.**
|
||||
- Opus encode/decode: `libopus` via cinterop, or pull `opus.framework`
|
||||
from a Swift Package / CocoaPods spec.
|
||||
- Mic + speaker: `AVAudioEngine` (input/output nodes) instead of
|
||||
`AudioRecord` / `AudioTrack`.
|
||||
|
||||
3. **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 `AVAudioSession` category + the `audio` background mode in
|
||||
`Info.plist`. Apple sometimes rejects apps that abuse this. Worth a
|
||||
separate audit before submission.
|
||||
- **Opus framework distribution.** `libopus` via 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 `:cli` analogue 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.
|
||||
Reference in New Issue
Block a user