- 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
28 KiB
title, type, status, date, owner, consumers, supersedes
| title | type | status | date | owner | consumers | supersedes |
|---|---|---|---|---|---|---|
| iOS app: the shared UI on iPhone and iPad, at feature parity with Android | feature | proposed | 2026-10-09 | commons | commons, commonsUI, quartz, quic, nestsClient, new iosApp | 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 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) reuses it unchanged, so Part A is written for both targets.
Where we start (measured 2026-10-09 on main @ 08885ee8)
Done:
quartz,commonsandcommonsUIdeclareiosArm64+iosSimulatorArm64.- CI runs
:quartztests on the iOS simulator (test-quartz-ios). - The lint job runs
verifyKmpPurityand thecommonMainmetadata compile for all three modules. commonMainis free of JVM-only APIs. That covers about 820k lines:quartz286k,commons195k,commonsUI339k.- The app root is shared:
LocalCacheandAccountare incommons/commonMain.AccountViewModel,AmethystApp, the navigation shell (AppShellLayout) and 238 of the 261 destinations are incommonsUI/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:
WebsocketBuilderhas only OkHttp implementations, injvmAndroid. - No HTTP client.
IRoleBasedHttpClientBuilderis an emptyactual interfaceon iOS. - No key storage.
SecureKeyStorage.ios.ktthrows "Keychain Services binding pending" on every call. - Stubbed platform pieces.
encodeQrMatrixreturnsnull,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,SecureKeyStorageVaultRoleBasedHttpClientBuilder,DualHttpClientManager(ForRelays),OkHttpWebSocketCacheClientConnector,RelayProxyClientConnector,NotifyCoordinatorNip11CachedRetriever,Nip86Retriever,NamecoinServicesPoWJobStore/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 andCordnGroupChat. 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
-
No iOS screens. A missing screen moves to
commonsUI(One-UI step 6). It does not get rewritten for iOS. -
Shared before Apple-specific.
- Code that needs no Apple API goes in a source set shared with web (
nonJvmMain, below), never iniosMain. - Only Keychain, AVFoundation, UIKit and other Apple frameworks go in
iosMain.
- Code that needs no Apple API goes in a source set shared with web (
-
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.
- OkHttp (Tor routing,
-
Kotlin first, Swift only where Apple requires it. Swift is used for:
- the
@mainentry 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
cinteropto Apple frameworks fromiosMain. - the
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
nonJvmMaininquartz,commonsandcommonsUI. It depends oncommonMain, andnativeMain/iosMaindepend on it. -
Move the 30 of 41
quartznativeMain/appleMainfiles that use noplatform.*orcinterop(checked 2026-10-09):- AES-GCM/CBC
- Ed25519/X25519
OptimizedJsonMapper,EventHasherSerializer,EventInternerLargeCache,ConcurrentHashCache,ConcurrentMap/Set,StripedHashMapUBigInt,BitSet,UrlEncoderScratchLocal,MultiplyHigh,FieldMulPlatformChessEngine,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.ktsto scansrc/nonJvmMainandsrc/nonJvmTest.
A2. HTTP and WebSockets on Ktor for non-JVM targets
-
Add Ktor client to
nonJvmMain:ktor-client-coreandktor-client-websockets;- the
ktor-client-darwinengine iniosMainand thektor-client-jsengine inwasmJsMain. - License: Apache-2.0. Checked in the
ktor-client-core3.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
commonMainwith an OkHttp implementation:Interface OkHttp implementation today WebSocket/WebsocketBuilderBasicOkHttpWebSocket,OkHttpWebSocketHttpRelayTransportOkHttpRelayTransportMintHttpTransportOkHttpMintTransportLnurlHttpTransport,LnurlEndpointResolverOkHttpLnurl*Nip05FetcherOkHttpNip05FetcherBitcoinExplorerOkHttpBitcoinExplorerRemoteCalendar,OtsResolverBuilderOkHttpCalendar,OkHttpOtsResolverBuilderTrustProviderHttpOkHttpTrustProviderHttpThe same code runs on iOS and web; only the engine differs.
-
Give
IRoleBasedHttpClientBuildera realnonJvmMainactual. It owns one KtorHttpClientper role:- relays, money, media, previews;
- each role later gains Tor routing on iOS (B12).
-
Images use
coil-network-ktor3(iOS + wasm variants published) incommonsUI/nonJvmMain. -
Exit criteria:
- an
iosSimulatorArm64Testconnects to a localgeode, sends a REQ and receives events; - the same test runs on JVM against OkHttp as a parity check.
- an
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→ okioPathonplatformFileSystem.ConcurrentHashMap→ quartzConcurrentMap/ConcurrentHashCache.OkHttpClientconstructor parameters → the A2 interfaces, orIRoleBasedHttpClientBuilder.java.text/java.time→ thecommonsdate and numberexpects, which already have iOS actuals.synchronized→KmpLock.
AccountCacheState comes first. Every shim builds accounts through it. Its blockers:
- OkHttp,
FileandConcurrentHashMap; - 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
MediaTranscoderport per platform.
Exit criteria:
desktopApp/…/desktop/app/imports nocommons/jvmAndroidclass except the OkHttp transport implementations it chooses to inject.- A
commonsiOS test builds anAccountfrom a stored nsec and loads its home feed againstgeode.
A4. Finish moving the last JVM-only screens into commonMain
- Markdown.
RenderMarkdown,RenderContentAsMarkdownandMarkdownMediaRendereruse the JitPack forkcom.github.vitorpamplona.compose-richtext, which is JVM/Android-only. That blocks the Git screens,CordnGroupChatand 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 tocommonMainunchanged. - The Android-registered destinations. Move them to
commonsUIwith their screens, behind slots orexpects 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(theMediaTranscoderfrom A3),MyFitness(a health-data port),WebApp/NostrApp(a web-view port; see B9).
- Pure UI:
- QR encoding.
encodeQrMatrixuses ZXing on JVM and returnsnullon iOS. Write one pure-Kotlin QR encoder incommonMainand use it on every platform. That deletes anexpect, and ZXing stays only where Android scans codes. - Nests screens. Move them from
commonsUI/jvmAndroidtocommonMain, behind the existingNestViewModelinterface. Audio itself is B11. - PDF.
PdfFetcherandPdfPageRendererbecome aPdfRendererport: PDFKit on iOS, PDFBox on desktop, Android'sPdfRendereron 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
:iosAppwithiosArm64andiosSimulatorArm64.src/iosMainis the shim. It mirrorsdesktopApp/…/desktop/app/:IosAppModules,IosAppRoot,IosAppServices,IosAppPlatform,IosAccountViewModelHost,IosLocalCacheHost,IosNotePlatform,IosRichTextPlatform.- It produces a static framework (
AmethystKit) and exportsfun MainViewController(): UIViewController, which wrapsComposeUIViewController { AmethystApp(…) }.
- The Xcode project. It lives at
iosApp/xcode/. A Swift@mainhosts the controller.- Build through
embedAndSignAppleFrameworkForXcode. - No CocoaPods. Swift-package SDKs are added in Xcode.
- Build through
- App lifecycle. Scene phase → the app's lifecycle owner, so the
AccountViewModelalways-on subscriptions pause and resume the way they do on Android. - CI.
- A macOS job builds
:iosAppfor the simulator and runscommons+commonsUIiosSimulatorArm64Testbeside the existingtest-quartz-ios. - It also builds the Xcode project unsigned.
- A macOS job builds
B2. Keys, accounts, biometrics
SecureKeyStorageon Keychain.- Uses
kSecClassGenericPasswordwithkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. - 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.
- Uses
DeviceAuthenticatoruses LocalAuthentication (Face ID / Touch ID).HideFromScreenshotsuses 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 (setAppPlatform.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 viaWalletAppLauncher.
- Login: nsec,
- Performance gate. Do not start B4 until it passes.
- Load a realistic
LocalCacheof 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
OptimizedJsonMapperinnonJvmMain; - caching that bounds
LocalCachegrowth (LargeSoftCache.ios.ktexists).
- a faster
- Load a realistic
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 useAVPlayerin aUIKitView. - Player pool. Mirrors the Android pool's limits, so a feed never holds more than N
AVPlayers. - Formats. HLS is native.
- Background audio. Uses the
AVAudioSessionplayback category, theaudiobackground mode, andMPNowPlayingInfoCenter+MPRemoteCommandCenter(the AndroidnowPlayingservice). - Picture-in-picture.
AVPictureInPictureControlleron the player layer. - Video disk cache.
AVAssetResourceLoaderDelegateserving from the shared Blossom/disk cache, or prefetch withAVAssetDownloadTask. - 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. MediaTranscoderport (A3).- Images: ImageIO re-encode with EXIF/GPS stripping.
- Video:
AVAssetExportSessionpresets. - HLS for
NewHlsVideo:AVAssetWritersegmenting.
- Uploads. Blossom, NIP-96 and NIP-95 run through the shared orchestrator (A3) on the A2 transports.
- Microphone.
RecordVoiceButton/RecordAudioBoxuseAVAudioRecorder, with Opus or AAC to match Android's format.
B6. Notifications
- What changes. Android keeps sockets open to the inbox and DM relays
(
NotificationRelayService, seePULL_NOTIFICATION.md). iOS cannot run that. iOS uses server push only. - Server work (outside this repo).
push.amethyst.socialsends to Apple's APNs directly (decided 2026-10-09), not through FCM. It needs the APNs auth key (.p8) and both push types:alertfor notifications andvoipfor 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.
- Registration reuses
- 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,EventNotificationConsumerand the renderers that are not Android-specific intocommonsfirst. Android then uses the same code.
- Actions.
- Inline reply →
UNTextInputNotificationAction(Android'sNotificationReplyReceiver). - Conversation grouping → thread identifiers (Android's
ConversationShortcuts).
- Inline reply →
- Catch-up.
BGAppRefreshTaskruns the logic ofNotificationCatchUpWorker. Timing is best effort. - NWC payment notifications.
NwcPaymentNotificationWatcherneeds 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
AndroidAppRootuses. Move its parsing tocommons.
- Register the
- Share extension. It hands text, images and video to the app through an App Group
container.
OnIncomingShareactual → theShareTargetrouting (new post, DM, highlight, picture, short, video, scan QR). - Outgoing share.
UIActivityViewController(TextSharer,FileSharer).ShareNoteAsImagerenders 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
ExternalSignerLoginButtonslot 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/consentmoves with A4).
B9. In-app browser, napplets and nSites
-
In-app browser with NIP-07. Android's browser (
nappletHost/…/NappletBrowserActivity,NappletBrowserService) injects awindow.nostrshim into every page and frame (addDocumentStartJavaScriptfor all origins). The shim talks to the broker throughaddWebMessageListener. iOS has the same capability:WKUserScriptat.atDocumentStartwithforMainFrameOnly = falseinjects the shim (BrowserWebTools.browserStartScriptmoves tocommons, so both platforms inject the same JS);WKScriptMessageHandlerWithReplyanswers the calls, and the message'sframeInfoidentifies 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
:nappletprocess and Messenger IPC withWKWebView. Its web content already runs out of process.WKScriptMessageHandlercarries the bridge.WKURLSchemeHandlerserves the nSite and napplet files, likenappletHost's content server.
-
What moves out of
nappletHost. The host-side logic that is not WebView-specific moves tocommons, 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
CallMediaEngineinterface that Swift implements. - Shared call logic. Signalling and state, from
WebRtcCallSessionandWebRtcPeerSessionAdapter, move tocommonsso both platforms share them. - CallKit for the system call UI and audio routing. Incoming calls arrive as APNs
voippushes frompush.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)
:quiciOS actuals.- UDP:
NWConnection(Network.framework) or POSIX sockets viacinterop. - AEAD: AES-GCM / ChaCha20-Poly1305 via the
cryptography-kotlinApple provider (already used inquartz), or CryptoKit. - The TLS state machine and codecs are already in
commonMain.
- UDP:
:nestsClientiOS actuals.- Opus encode/decode: AudioToolbox if it handles Opus well enough, else libopus
(BSD-3-Clause) through
cinterop. - Capture and playback with
AVAudioEngine.
- Opus encode/decode: AudioToolbox if it handles Opus well enough, else libopus
(BSD-3-Clause) through
- Order. The moq-lite listener path first, then the speaker path. Same order as the production Android path.
- Background audio. Uses the
audiobackground mode, with anAVAudioSessionaudit before submission.
B12. Tor
-
Library.
kmp-torruntime 2.6.0 andresource-noexec-tor409.5.0 publishios_arm64andios_simulator_arm64variants (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 17proxyConfigurationsAPI.- 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
ScreenLayoutSpecalready 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.
- Two separate payment rules. Quoted from Apple's guidelines page, read 2026-10-09:
- Distribution. TestFlight internal → external → App Store.
- Localization. Comes from Compose resources. Add
InfoPlist.stringsfor 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
- A1 → A2 → A3, plus B1 and B2 in parallel once A2 lands.
- B3 slice. Gate: the performance numbers meet the bar the maintainer sets.
- A4 and A5 run alongside B4–B7.
- B4 → B5 → B6 → B7: media, uploads, notifications, links. After these, the app can go to TestFlight for daily use.
- B8 → B9 → B10 → B11 → B12 → B13, in any order the maintainer prefers. These are independent areas.
- 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).