mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 07:37:38 +00:00
- iOS: push goes to APNs directly; the richtext fork gains iOS and wasm targets; scheduled posts are hidden on iOS. - iOS: split the App Store payment risks by rule, quoting Apple's text. Zaps fall under 3.2.1(vii) (gifts tied to content). The Cashu and on-chain wallets hold funds and fall under 3.1.5(i) (organization account). LNURL-via-external-wallet and NWC hold nothing. - iOS: the in-app browser injects NIP-07 into every page through WKUserScript, matching Android's addDocumentStartJavaScript. - Web: put the plan on hold. A browser tab cannot inject window.nostr into cross-origin frames, so its in-app browser can't offer NIP-07 to arbitrary sites, which was the maintainer's condition. Lists the options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019uPKNfzNbAgxKLAPYqbBxH
542 lines
28 KiB
Markdown
542 lines
28 KiB
Markdown
---
|
||
title: "iOS app: the shared UI on iPhone and iPad, at feature parity with Android"
|
||
type: feature
|
||
status: proposed
|
||
date: 2026-10-09
|
||
owner: commons
|
||
consumers: commons, commonsUI, quartz, quic, nestsClient, new iosApp
|
||
supersedes: amethyst/plans/2026-05-24-ios-support.md
|
||
---
|
||
|
||
# iOS app at feature parity with Android
|
||
|
||
## Goal
|
||
|
||
Ship Amethyst on iPhone and iPad with **every feature Android has**. Each feature either works
|
||
the same way or gets the closest design iOS allows (see "What iOS does not allow").
|
||
|
||
The iOS app is a **third shim** over the one shared UI, built the same way as the new desktop app
|
||
(`desktopApp/…/desktop/app/`, see
|
||
[2026-09-27-one-ui-android-desktop.md](2026-09-27-one-ui-android-desktop.md) step 8). It renders
|
||
`AmethystApp` from `commonsUI` and fills the same ports. It has **no screens of its own**.
|
||
|
||
This plan replaces `amethyst/plans/2026-05-24-ios-support.md`. That plan predates One-UI: it
|
||
proposed a native UIKit tab bar around shared content, and it stopped at "read/write minus audio
|
||
rooms".
|
||
|
||
Part A builds groundwork that is not specific to iOS. The web plan
|
||
([2026-10-09-web-app.md](2026-10-09-web-app.md)) reuses it unchanged, so Part A is written for
|
||
both targets.
|
||
|
||
## Where we start (measured 2026-10-09 on `main` @ `08885ee8`)
|
||
|
||
**Done:**
|
||
- `quartz`, `commons` and `commonsUI` declare `iosArm64` + `iosSimulatorArm64`.
|
||
- CI runs `:quartz` tests on the iOS simulator (`test-quartz-ios`).
|
||
- The lint job runs `verifyKmpPurity` and the `commonMain` metadata compile for all three modules.
|
||
- `commonMain` is free of JVM-only APIs. That covers about 820k lines: `quartz` 286k, `commons`
|
||
195k, `commonsUI` 339k.
|
||
- The app root is shared:
|
||
- `LocalCache` and `Account` are in `commons/commonMain`.
|
||
- `AccountViewModel`, `AmethystApp`, the navigation shell (`AppShellLayout`) and 238 of the
|
||
261 destinations are in `commonsUI/commonMain`.
|
||
- Navigation 3 publishes iOS variants.
|
||
|
||
**Compile-only, so nothing runs yet:**
|
||
- **No app target.** No Xcode project and no framework binary.
|
||
- **No networking.** There is no relay socket on iOS: `WebsocketBuilder` has only OkHttp
|
||
implementations, in `jvmAndroid`.
|
||
- **No HTTP client.** `IRoleBasedHttpClientBuilder` is an empty `actual interface` on iOS.
|
||
- **No key storage.** `SecureKeyStorage.ios.kt` throws "Keychain Services binding pending" on
|
||
every call.
|
||
- **Stubbed platform pieces.** `encodeQrMatrix` returns `null`, `canCaptureFromCamera = false`,
|
||
and several pickers are stubs.
|
||
|
||
**What the desktop shim needs that is still JVM-only** (the graph a shim builds). These are
|
||
`commons/jvmAndroid` classes that `desktopApp/…/desktop/app/` imports:
|
||
- `AccountCacheState`, `StoreAccountSessionStore`, `SecureKeyStorageVault`
|
||
- `RoleBasedHttpClientBuilder`, `DualHttpClientManager(ForRelays)`, `OkHttpWebSocket`
|
||
- `CacheClientConnector`, `RelayProxyClientConnector`, `NotifyCoordinator`
|
||
- `Nip11CachedRetriever`, `Nip86Retriever`, `NamecoinServices`
|
||
- `PoWJobStore` / `PowJobRestorer`, `BlossomClient`, `ImageDownloader`, `ServerInfoRetriever`
|
||
- the crash-report classes
|
||
- the Marmot encrypted stores
|
||
- `AccountsTorStateConnector`, `TorRelayState`
|
||
|
||
Why they are JVM-only, counting files across the 127 in `commons/jvmAndroid`:
|
||
|
||
| Reason | Files |
|
||
|---|---|
|
||
| OkHttp | 45 |
|
||
| `java.util.concurrent` | 18 |
|
||
| `java.net` | 16 |
|
||
| `java.io.File` / `java.nio` | 15 |
|
||
| `java.text` / `java.time` / `Locale` | 13 |
|
||
|
||
**Screens that only exist on JVM/Android today:**
|
||
- Registered by the Android app, `amethyst/…/ui/navigation/AppNavigation.kt`: `MyFitness`,
|
||
`WebApp`, `NostrApp`, `ConnectedAppDetail`, `NewHlsVideo`, `ScanQrImage`,
|
||
`NowPlayingSettings`, `ResourceUsage`, `UpdateReactionType`, `ShareNoteAsImage(File)`.
|
||
- In `commonsUI/jvmAndroid` (`JvmDestinations.kt`): the Git repository screens and
|
||
`CordnGroupChat`. Both depend on the richtext markdown fork.
|
||
- Also in `commonsUI/jvmAndroid`: the audio-room (nests) screens, the PDF viewer and the
|
||
fitness dashboard.
|
||
|
||
## Reuse rules for this plan
|
||
|
||
1. **No iOS screens.** A missing screen moves to `commonsUI` (One-UI step 6). It does not get
|
||
rewritten for iOS.
|
||
2. **Shared before Apple-specific.**
|
||
- Code that needs no Apple API goes in a source set shared with web (`nonJvmMain`, below),
|
||
never in `iosMain`.
|
||
- Only Keychain, AVFoundation, UIKit and other Apple frameworks go in `iosMain`.
|
||
3. **JVM/Android keep their stack.**
|
||
- OkHttp (Tor routing, `SurgeDns`, connection tuning), Jackson and the JNI secp256k1 stay on
|
||
JVM. Hot paths must not regress.
|
||
- The new implementations sit beside them behind the interfaces that already exist.
|
||
4. **Kotlin first, Swift only where Apple requires it.** Swift is used for:
|
||
- the `@main` entry point;
|
||
- app extensions (Notification Service, Share, Broadcast Upload): thin wrappers that call the
|
||
Kotlin framework;
|
||
- SDKs that only ship as Swift packages (WebRTC, Google Cast): bridged through a Kotlin
|
||
interface that Swift implements and hands to the framework at startup.
|
||
|
||
Everything else uses `cinterop` to Apple frameworks from `iosMain`.
|
||
|
||
## Target source-set layout
|
||
|
||
```
|
||
commonMain
|
||
├── jvmAndroid ── jvmMain, androidMain OkHttp, Jackson, JCA, JNI secp256k1
|
||
└── nonJvmMain Ktor client, pure-Kotlin actuals (NEW, shared with web)
|
||
├── nativeMain ── appleMain ── iosMain cinterop: Keychain, CryptoKit, AVFoundation, UIKit
|
||
└── wasmJsMain browser interop (web plan)
|
||
|
||
skikoMain = jvmMain + iosMain (+ wasmJsMain, web plan) commonsUI only, exists today
|
||
```
|
||
|
||
`quartz` already has `nativeMain → appleMain → iosMain` (plus `linuxMain`, `macosMain`).
|
||
`nonJvmMain` slots in above `nativeMain`.
|
||
|
||
## Part A — Groundwork shared with the web app
|
||
|
||
### A1. Add `nonJvmMain` and move the reusable native code into it
|
||
|
||
- Create `nonJvmMain` in `quartz`, `commons` and `commonsUI`. It depends on `commonMain`, and
|
||
`nativeMain`/`iosMain` depend on it.
|
||
- Move the **30 of 41** `quartz` `nativeMain`/`appleMain` files that use no `platform.*` or
|
||
`cinterop` (checked 2026-10-09):
|
||
- AES-GCM/CBC
|
||
- Ed25519/X25519
|
||
- `OptimizedJsonMapper`, `EventHasherSerializer`, `EventInterner`
|
||
- `LargeCache`, `ConcurrentHashCache`, `ConcurrentMap/Set`, `StripedHashMap`
|
||
- `UBigInt`, `BitSet`, `UrlEncoder`
|
||
- `ScratchLocal`, `MultiplyHigh`, `FieldMulPlatform`
|
||
- `ChessEngine`, `Address`, `CertificateSignature`, `MacInstance`, `DigestInstance`
|
||
- the buffer helpers
|
||
|
||
Their only non-trivial imports are:
|
||
- `kotlin.concurrent.atomics`, which is common in Kotlin 2.4;
|
||
- `dev.whyoleg.cryptography`. On web this needs care; see the web plan, W1.
|
||
- Extend `gradle/kmp-purity.gradle.kts` to scan `src/nonJvmMain` and `src/nonJvmTest`.
|
||
|
||
### A2. HTTP and WebSockets on Ktor for non-JVM targets
|
||
|
||
- Add Ktor client to `nonJvmMain`:
|
||
- `ktor-client-core` and `ktor-client-websockets`;
|
||
- the `ktor-client-darwin` engine in `iosMain` and the `ktor-client-js` engine in `wasmJsMain`.
|
||
- License: Apache-2.0. Checked in the `ktor-client-core` 3.6.0 POM on 2026-10-09; recheck
|
||
each new artifact's POM when adding it.
|
||
- Ktor 3.6.0 is already the catalog version (used by `geode`).
|
||
- Write one Ktor implementation per transport interface that already exists in `commonMain`
|
||
with an OkHttp implementation:
|
||
|
||
| Interface | OkHttp implementation today |
|
||
|---|---|
|
||
| `WebSocket` / `WebsocketBuilder` | `BasicOkHttpWebSocket`, `OkHttpWebSocket` |
|
||
| `HttpRelayTransport` | `OkHttpRelayTransport` |
|
||
| `MintHttpTransport` | `OkHttpMintTransport` |
|
||
| `LnurlHttpTransport`, `LnurlEndpointResolver` | `OkHttpLnurl*` |
|
||
| `Nip05Fetcher` | `OkHttpNip05Fetcher` |
|
||
| `BitcoinExplorer` | `OkHttpBitcoinExplorer` |
|
||
| `RemoteCalendar`, `OtsResolverBuilder` | `OkHttpCalendar`, `OkHttpOtsResolverBuilder` |
|
||
| `TrustProviderHttp` | `OkHttpTrustProviderHttp` |
|
||
|
||
The same code runs on iOS and web; only the engine differs.
|
||
- Give `IRoleBasedHttpClientBuilder` a real `nonJvmMain` actual. It owns one Ktor `HttpClient`
|
||
per role:
|
||
- relays, money, media, previews;
|
||
- each role later gains Tor routing on iOS (B12).
|
||
- Images use `coil-network-ktor3` (iOS + wasm variants published) in `commonsUI/nonJvmMain`.
|
||
- Exit criteria:
|
||
- an `iosSimulatorArm64Test` connects to a local `geode`, sends a REQ and receives events;
|
||
- the same test runs on JVM against OkHttp as a parity check.
|
||
|
||
### A3. Make the shim's graph JVM-free
|
||
|
||
Move each `commons/jvmAndroid` class listed under "Where we start" to `commonMain`. Use the same
|
||
recipe One-UI used for `Account` (cut each seam, then move), and measure first with the closure
|
||
method in the One-UI plan ("Method").
|
||
|
||
Typical cuts:
|
||
- `java.io.File` → okio `Path` on `platformFileSystem`.
|
||
- `ConcurrentHashMap` → quartz `ConcurrentMap` / `ConcurrentHashCache`.
|
||
- `OkHttpClient` constructor parameters → the A2 interfaces, or `IRoleBasedHttpClientBuilder`.
|
||
- `java.text` / `java.time` → the `commons` date and number `expect`s, which already have iOS
|
||
actuals.
|
||
- `synchronized` → `KmpLock`.
|
||
|
||
**`AccountCacheState` comes first.** Every shim builds accounts through it. Its blockers:
|
||
- OkHttp, `File` and `ConcurrentHashMap`;
|
||
- six Marmot stores in `jvmAndroid`;
|
||
- `OkHttpMintTransport`.
|
||
|
||
**`UploadOrchestrator`, `MediaCompressor` and `MediaMetadata` are in `commons/jvmMain`.**
|
||
- Split the orchestration, which is platform-free, from the codecs. Image and video re-encode
|
||
becomes a `MediaTranscoder` port per platform.
|
||
|
||
**Exit criteria:**
|
||
- `desktopApp/…/desktop/app/` imports no `commons/jvmAndroid` class except the OkHttp transport
|
||
implementations it chooses to inject.
|
||
- A `commons` iOS test builds an `Account` from a stored nsec and loads its home feed against
|
||
`geode`.
|
||
|
||
### A4. Finish moving the last JVM-only screens into `commonMain`
|
||
|
||
- **Markdown.** `RenderMarkdown`, `RenderContentAsMarkdown` and `MarkdownMediaRenderer` use the
|
||
JitPack fork `com.github.vitorpamplona.compose-richtext`, which is JVM/Android-only. That
|
||
blocks the Git screens, `CordnGroupChat` and long-form articles on iOS and web.
|
||
**Decided 2026-10-09:** the maintainer publishes iOS and wasm targets from the fork, so the
|
||
renderer behaves the same everywhere. These files then move to `commonMain` unchanged.
|
||
- **The Android-registered destinations.** Move them to `commonsUI` with their screens, behind
|
||
slots or `expect`s where they touch Android:
|
||
- Pure UI: `UpdateReactionType`, `ResourceUsage` (the data source becomes a port),
|
||
`NowPlayingSettings`, `ConnectedAppDetail`.
|
||
- Need new ports: `ShareNoteAsImage` (rendering to a bitmap needs a "save/share image" port),
|
||
`ScanQrImage` (a "decode QR from image" port), `NewHlsVideo` (the `MediaTranscoder` from A3),
|
||
`MyFitness` (a health-data port), `WebApp` / `NostrApp` (a web-view port; see B9).
|
||
- **QR encoding.** `encodeQrMatrix` uses ZXing on JVM and returns `null` on iOS. Write one
|
||
pure-Kotlin QR encoder in `commonMain` and use it on every platform. That deletes an `expect`,
|
||
and ZXing stays only where Android scans codes.
|
||
- **Nests screens.** Move them from `commonsUI/jvmAndroid` to `commonMain`, behind the existing
|
||
`NestViewModel` interface. Audio itself is B11.
|
||
- **PDF.** `PdfFetcher` and `PdfPageRenderer` become a `PdfRenderer` port: PDFKit on iOS, PDFBox
|
||
on desktop, Android's `PdfRenderer` on Android.
|
||
|
||
### A5. Coroutines: one IO dispatcher name for every target
|
||
|
||
- Kotlin/Native has `Dispatchers.IO`, so iOS needs no change.
|
||
- Web does not have it, so web plan W1 owns this change. Do it during Part A anyway: it is a
|
||
single mechanical PR, and doing it early keeps iOS and web on the same `commonMain`.
|
||
|
||
## Part B — The iOS app
|
||
|
||
### B1. Build and app skeleton
|
||
|
||
- **The shim module.** Add a Gradle KMP module `:iosApp` with `iosArm64` and
|
||
`iosSimulatorArm64`.
|
||
- `src/iosMain` is the shim. It mirrors `desktopApp/…/desktop/app/`: `IosAppModules`,
|
||
`IosAppRoot`, `IosAppServices`, `IosAppPlatform`, `IosAccountViewModelHost`,
|
||
`IosLocalCacheHost`, `IosNotePlatform`, `IosRichTextPlatform`.
|
||
- It produces a static framework (`AmethystKit`) and exports
|
||
`fun MainViewController(): UIViewController`, which wraps `ComposeUIViewController { AmethystApp(…) }`.
|
||
- **The Xcode project.** It lives at `iosApp/xcode/`. A Swift `@main` hosts the controller.
|
||
- Build through `embedAndSignAppleFrameworkForXcode`.
|
||
- No CocoaPods. Swift-package SDKs are added in Xcode.
|
||
- **App lifecycle.** Scene phase → the app's lifecycle owner, so the `AccountViewModel`
|
||
always-on subscriptions pause and resume the way they do on Android.
|
||
- **CI.**
|
||
- A macOS job builds `:iosApp` for the simulator and runs `commons` + `commonsUI`
|
||
`iosSimulatorArm64Test` beside the existing `test-quartz-ios`.
|
||
- It also builds the Xcode project unsigned.
|
||
|
||
### B2. Keys, accounts, biometrics
|
||
|
||
- **`SecureKeyStorage` on Keychain.**
|
||
- Uses `kSecClassGenericPassword` with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.
|
||
- The Keychain access group is shared with the Notification Service Extension (B6).
|
||
- `StoreAccountSessionStore` (shared after A3) keeps the roster through DataStore on the app
|
||
container's okio path.
|
||
- **`DeviceAuthenticator`** uses LocalAuthentication (Face ID / Touch ID).
|
||
- **`HideFromScreenshots`** uses the secure-text-field layer trick. It blanks the content in
|
||
screenshots and recordings.
|
||
|
||
### B3. First vertical slice and the performance gate
|
||
|
||
- **Scope.**
|
||
- Login: nsec, `ncryptsec`, read-only npub, and NIP-46 bunker (set
|
||
`AppPlatform.supportsRemoteSignerLogin = true`, like desktop).
|
||
- Home, profiles, threads, notifications, search and the composer.
|
||
- Reactions, reposts and DMs (NIP-17).
|
||
- Zaps: LNURL and NWC in-app; external wallets through `lightning:` URLs via
|
||
`WalletAppLauncher`.
|
||
- **Performance gate. Do not start B4 until it passes.**
|
||
- Load a realistic `LocalCache` of 10k+ notes and 2k+ users from a recorded corpus, so the
|
||
numbers can be re-run.
|
||
- Measure on a mid-range iPhone:
|
||
- feed scroll frame time (Compose iOS);
|
||
- event ingest throughput: parse + verify + index, where the pure-Kotlin JSON mapper and
|
||
native secp256k1 replace Jackson and the JNI library;
|
||
- steady-state memory, against iOS jetsam limits;
|
||
- Kotlin/Native GC pause times.
|
||
- Compare against Android on similar hardware. Fixes for gaps go into shared code, so
|
||
everyone benefits. Examples:
|
||
- a faster `OptimizedJsonMapper` in `nonJvmMain`;
|
||
- caching that bounds `LocalCache` growth (`LargeSoftCache.ios.kt` exists).
|
||
|
||
### B4. Note media and playback (largest single area)
|
||
|
||
Android's `service/playback` is 63 files and 9.1k lines on media3.
|
||
|
||
- **Player.** `IosNotePlatform.VideoView` / `FullscreenVideoView` / audio renderers use
|
||
`AVPlayer` in a `UIKitView`.
|
||
- **Player pool.** Mirrors the Android pool's limits, so a feed never holds more than N
|
||
`AVPlayer`s.
|
||
- **Formats.** HLS is native.
|
||
- **Background audio.** Uses the `AVAudioSession` playback category, the `audio` background
|
||
mode, and `MPNowPlayingInfoCenter` + `MPRemoteCommandCenter` (the Android `nowPlaying` service).
|
||
- **Picture-in-picture.** `AVPictureInPictureController` on the player layer.
|
||
- **Video disk cache.** `AVAssetResourceLoaderDelegate` serving from the shared Blossom/disk
|
||
cache, or prefetch with `AVAssetDownloadTask`.
|
||
- **GIFs and animated images.** Coil animated decoding, if the iOS decoder supports it. If not,
|
||
frame extraction with ImageIO in `AnimatedImageAutoPlay.ios`.
|
||
- **Casting.** AirPlay through `AVRoutePickerView`; Google Cast in B13.
|
||
|
||
### B5. Media capture and uploads
|
||
|
||
- **Pickers.**
|
||
- `GallerySelect` / `GallerySelectSingle` → `PHPickerViewController`.
|
||
- `FileSelect` / `DocumentSelectSingle` → `UIDocumentPickerViewController`.
|
||
- **Camera.** `TakePicture` / `TakeVideo` → `UIImagePickerController`, or an AVCapture screen
|
||
for in-app capture. `canCaptureFromCamera = true`.
|
||
- **`MediaTranscoder` port (A3).**
|
||
- Images: ImageIO re-encode with EXIF/GPS stripping.
|
||
- Video: `AVAssetExportSession` presets.
|
||
- HLS for `NewHlsVideo`: `AVAssetWriter` segmenting.
|
||
- **Uploads.** Blossom, NIP-96 and NIP-95 run through the shared orchestrator (A3) on the A2
|
||
transports.
|
||
- **Microphone.** `RecordVoiceButton` / `RecordAudioBox` use `AVAudioRecorder`, with Opus or
|
||
AAC to match Android's format.
|
||
|
||
### B6. Notifications
|
||
|
||
- **What changes.** Android keeps sockets open to the inbox and DM relays
|
||
(`NotificationRelayService`, see `PULL_NOTIFICATION.md`). iOS cannot run that. iOS uses
|
||
server push only.
|
||
- **Server work (outside this repo).** `push.amethyst.social` sends to Apple's APNs directly
|
||
(decided 2026-10-09), not through FCM. It needs the APNs auth key (`.p8`) and both push
|
||
types: `alert` for notifications and `voip` for calls (B10).
|
||
- Registration reuses `RegisterAccounts.kt`'s payload with an APNs token.
|
||
- Document the coverage loss: relays the push server does not watch are only caught when the
|
||
app opens.
|
||
- **Notification Service Extension (Swift wrapper + `AmethystKit`).**
|
||
- Decrypts NIP-59 gift wraps with the key from the shared Keychain group.
|
||
- Enriches with names and pictures from a small shared cache.
|
||
- Must fit the extension's memory and time budget, so it uses the Kotlin event parser
|
||
without `LocalCache`.
|
||
- Extract the parts of `PushWrapDecryptor`, `NotificationEnricher`,
|
||
`EventNotificationConsumer` and the renderers that are not Android-specific into `commons`
|
||
first. Android then uses the same code.
|
||
- **Actions.**
|
||
- Inline reply → `UNTextInputNotificationAction` (Android's `NotificationReplyReceiver`).
|
||
- Conversation grouping → thread identifiers (Android's `ConversationShortcuts`).
|
||
- **Catch-up.** `BGAppRefreshTask` runs the logic of `NotificationCatchUpWorker`. Timing is
|
||
best effort.
|
||
- **NWC payment notifications.** `NwcPaymentNotificationWatcher` needs a live socket, so it
|
||
works while the app is open and through push otherwise.
|
||
|
||
### B7. Links, sharing, clipboard
|
||
|
||
- **Incoming links.**
|
||
- Register the `nostr:` URL scheme.
|
||
- Universal links for the web app's domain, once the web plan picks one.
|
||
- Both route through the same intent router `AndroidAppRoot` uses. Move its parsing to
|
||
`commons`.
|
||
- **Share extension.** It hands text, images and video to the app through an App Group
|
||
container. `OnIncomingShare` actual → the `ShareTarget` routing (new post, DM, highlight,
|
||
picture, short, video, scan QR).
|
||
- **Outgoing share.** `UIActivityViewController` (`TextSharer`, `FileSharer`). `ShareNoteAsImage`
|
||
renders through the A4 port.
|
||
- **Clipboard.** `UIPasteboard`. Sensitive text uses local-only, expiring items.
|
||
|
||
### B8. Signers and connected apps
|
||
|
||
- **NIP-55.** There is no equivalent: no intents. The `ExternalSignerLoginButton` slot stays
|
||
empty, and onboarding points to NIP-46.
|
||
- **Amethyst as a bunker for other apps (`connectedApps`).**
|
||
- It serves requests while the app is in the foreground.
|
||
- Optionally, a push from the bunker relay wakes the app for a consent notification. This
|
||
needs server support; decide in B8.
|
||
- The consent UI is shared (`connectedApps/consent` moves with A4).
|
||
|
||
### B9. In-app browser, napplets and nSites
|
||
|
||
- **In-app browser with NIP-07.** Android's browser (`nappletHost/…/NappletBrowserActivity`,
|
||
`NappletBrowserService`) injects a `window.nostr` shim into every page and frame
|
||
(`addDocumentStartJavaScript` for all origins). The shim talks to the broker through
|
||
`addWebMessageListener`. iOS has the same capability:
|
||
- `WKUserScript` at `.atDocumentStart` with `forMainFrameOnly = false` injects the shim
|
||
(`BrowserWebTools.browserStartScript` moves to `commons`, so both platforms inject the same
|
||
JS);
|
||
- `WKScriptMessageHandlerWithReply` answers the calls, and the message's `frameInfo`
|
||
identifies the requesting origin.
|
||
|
||
Arbitrary websites therefore get NIP-07 on iOS just as on Android.
|
||
- **Shared broker.** The protocol and broker already live in `commons/…/napplet/`
|
||
(`NappletBroker`, capabilities, held requests). Keep the broker shared.
|
||
- **iOS sandbox.** iOS replaces Android's `:napplet` process and Messenger IPC with `WKWebView`.
|
||
Its web content already runs out of process.
|
||
- `WKScriptMessageHandler` carries the bridge.
|
||
- `WKURLSchemeHandler` serves the nSite and napplet files, like `nappletHost`'s content server.
|
||
- **What moves out of `nappletHost`.** The host-side logic that is not WebView-specific moves to
|
||
`commons`, so Android and iOS share it.
|
||
- **App Store.** Review against guideline 4.7 (HTML5 mini-apps): they must not expose native
|
||
platform APIs. Signing and relay access are app features, not native APIs, but write the case
|
||
down before submission.
|
||
|
||
### B10. Calls (WebRTC)
|
||
|
||
- **WebRTC.** The iOS build of WebRTC comes as a Swift package. Check its license (the upstream
|
||
WebRTC license is BSD-3-Clause). It sits behind a Kotlin `CallMediaEngine` interface that
|
||
Swift implements.
|
||
- **Shared call logic.** Signalling and state, from `WebRtcCallSession` and
|
||
`WebRtcPeerSessionAdapter`, move to `commons` so both platforms share them.
|
||
- **CallKit** for the system call UI and audio routing. Incoming calls arrive as APNs `voip`
|
||
pushes from `push.amethyst.social` (B6).
|
||
- **Incoming calls while the app is closed.** These need PushKit VoIP pushes, which the push
|
||
server must send (B6). Apple requires every VoIP push to report a CallKit call.
|
||
- **Screen share.** A ReplayKit Broadcast Upload Extension.
|
||
|
||
### B11. Audio rooms (`quic` + `nestsClient`)
|
||
|
||
- **`:quic` iOS actuals.**
|
||
- UDP: `NWConnection` (Network.framework) or POSIX sockets via `cinterop`.
|
||
- AEAD: AES-GCM / ChaCha20-Poly1305 via the `cryptography-kotlin` Apple provider (already used
|
||
in `quartz`), or CryptoKit.
|
||
- The TLS state machine and codecs are already in `commonMain`.
|
||
- **`:nestsClient` iOS actuals.**
|
||
- Opus encode/decode: AudioToolbox if it handles Opus well enough, else libopus
|
||
(BSD-3-Clause) through `cinterop`.
|
||
- Capture and playback with `AVAudioEngine`.
|
||
- **Order.** The moq-lite listener path first, then the speaker path. Same order as the
|
||
production Android path.
|
||
- **Background audio.** Uses the `audio` background mode, with an `AVAudioSession` audit before
|
||
submission.
|
||
|
||
### B12. Tor
|
||
|
||
- **Library.** `kmp-tor` runtime 2.6.0 and `resource-noexec-tor` 409.5.0 publish `ios_arm64`
|
||
and `ios_simulator_arm64` variants (checked in their Gradle metadata on 2026-10-09). On iOS,
|
||
Tor runs in-process, because iOS forbids starting a separate executable.
|
||
- **Open question: routing traffic through it.** Can a Ktor engine on iOS send traffic through
|
||
Tor's SOCKS port?
|
||
- `NSURLSession` (Darwin engine): SOCKS support on iOS is the question. Check the iOS 17
|
||
`proxyConfigurations` API.
|
||
- CIO engine: no SOCKS support.
|
||
|
||
Spike it at the start of B12. Without it, Tor on iOS covers only what can be routed.
|
||
- **The rest is shared.** The relay-evaluation and per-role routing logic (`TorRelayState`,
|
||
`torEvaluatorFlow`) is shared after A3.
|
||
|
||
### B13. Remaining platform features
|
||
|
||
| Android | iOS |
|
||
|---|---|
|
||
| Google Cast (`play` flavour) | Google Cast iOS SDK (Swift package) behind `AppPlatform.isCastingAvailable` |
|
||
| TTS (`service/tts`) | `AVSpeechSynthesizer` |
|
||
| Location, geohash, "around me" | CoreLocation (`rememberCoarseLocationPermission`, `LocationPreviewMap`, `LoadCityName`) |
|
||
| Calendar (`service/calendar`, `rememberPhoneCalendar`) | EventKit |
|
||
| Workouts / Health Connect | HealthKit, behind the A4 health port |
|
||
| ML Kit translation + language ID (`play`) | Apple Translation framework + NaturalLanguage, behind `TranslationPlatform` |
|
||
| ML Kit GenAI proofread/rewrite/describe (`play`) | Apple Foundation Models on devices that have it; hidden otherwise |
|
||
| PoW (`service/pow`) | Runs in the foreground; `BGProcessingTask` resumes it, best effort |
|
||
| Scheduled posts (WorkManager) | **Hidden on iOS** (decided 2026-10-09). `AccountViewModelHost.scheduledPostStore` stays `null`, which `ShortNotePostViewModel` already treats as "no scheduling". A new `AppPlatform.supportsScheduledPosts = false` hides the drawer entry, the account-sheet count and the scheduled-posts screen; `AppServices.scheduledPostStore` gets an empty implementation |
|
||
| Resource usage / battery ledger | Memory and network counters only; iOS exposes no per-app battery figures |
|
||
| Connectivity callbacks | `NWPathMonitor` |
|
||
| `KeepScreenBrightAndAwake` | `UIApplication.isIdleTimerDisabled` |
|
||
| QR scanner (`QrCodeScannerDialog`) | `AVCaptureMetadataOutput` |
|
||
| `phonePrefersMiles`, languages, number and date formats | Already have iOS actuals; verify output against Android |
|
||
|
||
### B14. iPad and Mac
|
||
|
||
- The shared `ScreenLayoutSpec` already picks the rail or the permanent drawer on wide
|
||
windows, and docks notifications at 1200dp+.
|
||
- iPad adds:
|
||
- hardware-keyboard shortcuts, reusing the desktop shortcut table once it is shared;
|
||
- pointer hover;
|
||
- Stage Manager window sizes.
|
||
- Mac: ship the iPad build as "Designed for iPad" on Apple silicon at no extra cost. No
|
||
Catalyst target; desktop stays the JVM app.
|
||
|
||
### B15. Release
|
||
|
||
- **App Store review checklist.** Prepare it before building the matching features:
|
||
- **Two separate payment rules.** Quoted from Apple's guidelines page, read 2026-10-09:
|
||
- **3.2.1(vii), gifts. Applies to every zap, on any rail.** *"Apps may enable individual
|
||
users to give a monetary gift to another individual without using in-app purchase,
|
||
provided that (a) the gift is a completely optional choice by the giver, and (b) 100% of
|
||
the funds go to the receiver of the gift. However, a gift that is connected to or
|
||
associated at any point in time with receiving digital content or services must use
|
||
in-app purchase."* This is what Apple raised with Damus in 2023: a zap on a post can be
|
||
read as paying for content. Zaps are optional and the recipient gets 100%, but they are
|
||
attached to notes. Prepare the rationale, and a per-region flag for zaps on content as a
|
||
fallback.
|
||
- **3.1.5(i), wallets. Applies to features where the app holds the funds.** *"Apps may
|
||
facilitate virtual currency storage, provided they are offered by developers enrolled as
|
||
an organization."* This covers:
|
||
- the NIP-60 Cashu wallet: the app holds the ecash proofs;
|
||
- the NIP-BC on-chain wallet (`OnchainWalletState`): one Taproot address derived from the
|
||
account key, with the app signing the spends.
|
||
|
||
It does not cover LNURL zaps paid by an external wallet, or NWC: there the funds stay in
|
||
the user's own wallet and the app only sends payment requests. Under an individual
|
||
account, ship with both wallets hidden.
|
||
- **Napplets** (4.7), see B9.
|
||
- **User-generated content** (1.2): a EULA at sign-up, plus report, block and mute that are
|
||
easy to find. They exist; check the iOS onboarding shows them.
|
||
- **Privacy manifest** (`NSPrivacyAccessedAPITypes`) and the encryption export declaration.
|
||
- **Distribution.** TestFlight internal → external → App Store.
|
||
- **Localization.** Comes from Compose resources. Add `InfoPlist.strings` for the permission
|
||
prompts.
|
||
|
||
## What iOS does not allow, and the design used instead
|
||
|
||
| Android feature | iOS limit | Design here |
|
||
|---|---|---|
|
||
| Always-on notification service | No background sockets | APNs through the push server + Notification Service Extension (B6) |
|
||
| NIP-55 signer apps; Amethyst signing for other apps in the background | No intents; no background serving | NIP-46 both ways, foreground-served (B8) |
|
||
| Scheduled posts at an exact time | Background tasks run when iOS decides | Hidden on iOS (B13) |
|
||
| Incoming calls while closed | Needs CallKit + PushKit | VoIP push from the push server (B10) |
|
||
|
||
## Order and gates
|
||
|
||
1. **A1 → A2 → A3**, plus B1 and B2 in parallel once A2 lands.
|
||
2. **B3 slice. Gate:** the performance numbers meet the bar the maintainer sets.
|
||
3. **A4 and A5** run alongside B4–B7.
|
||
4. **B4 → B5 → B6 → B7**: media, uploads, notifications, links. After these, the app can go to
|
||
TestFlight for daily use.
|
||
5. **B8 → B9 → B10 → B11 → B12 → B13**, in any order the maintainer prefers. These are
|
||
independent areas.
|
||
6. **B14, B15.**
|
||
|
||
## Risks
|
||
|
||
- **Kotlin/Native performance and memory** with a large `LocalCache` (the B3 gate). Fixes land
|
||
in shared code.
|
||
- **Compose iOS feel.** Text selection, IME behaviour, scroll physics and VoiceOver. Test on
|
||
devices from B3 on. Report gaps upstream rather than forking.
|
||
- **Push server work is outside this repo** and blocks B6 and B10.
|
||
- **App Store review** (B15). Zaps on content may need a per-region flag (3.2.1(vii)); the
|
||
Cashu and on-chain wallets need an organization account (3.1.5(i)).
|
||
- **Tor routing on iOS** may be partial (B12).
|
||
|
||
## Open questions
|
||
|
||
- An organization or individual Apple developer account? This decides whether the Cashu and
|
||
on-chain wallets can ship (B15).
|
||
|
||
Decided 2026-10-09:
|
||
- push goes to APNs directly (B6);
|
||
- the richtext fork gains iOS and wasm targets (A4);
|
||
- scheduled posts are hidden on iOS (B13).
|