L1: foreground relay subscription (~25x speedup while Clave is open) - #11
Merged
Conversation
@mainactor@observable final class with public start/stop/resetCounters API surface and Observable counter properties. Dispatcher logic empty; filled in subsequent tasks. First @mainactor introduction in Clave — documented as deliberate in source comment. Spec: ~/hq/clave/specs/2026-04-26-foreground-bulk-decrypt-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Shared/SharedStorage.swift markEventProcessed(eventId:, createdAt:) helper with insertion-ordered ring buffer (200 entries) and age-bounded eviction (60s by event.created_at). Replaces the prior Set + .suffix(50) pattern duplicated inline in two call sites. Audit follow-up D.1.1 is closed by this commit. The two inline call sites (ClaveApp.swift, NotificationService.swift) are migrated in later commits. Cross-process semantics are deliberately lossy — NSE and main app race on UserDefaults, occasional duplicate inserts produce double-publishes which NIP-46 clients dedupe via response id. Within one process, NSLock ensures atomic check-and-insert (test coverage). 5 unit tests added (ClaveTests/SharedStorageDedupTests.swift). Spec: ~/hq/clave/specs/2026-04-26-foreground-bulk-decrypt-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Calls SharedStorage.markEventProcessed early in handleRequest, before any decryption work. Returns 'skipped-duplicate' status when the event was already processed by another path (NSE vs Layer 1 foreground sub). This is the single dedupe choke point both NSE and Layer 1 inherit. The duplicate inline checks in ClaveApp.swift / NotificationService.swift become redundant and are removed in the next commit. Handles both Double and Int decoding of `created_at` (Nostr relays send the field as a number; JSONSerialization may surface it as either type). All existing tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LightSigner.handleRequest now performs the dedupe check at the choke point (Task 3). The duplicate inline blocks in ClaveApp.swift (foreground push handler) and NotificationService.swift (NSE) are no longer needed. Both call sites now treat status="skipped-duplicate" as a no-op continue. Decrypt-failure handling is unchanged. Net: fewer storage round-trips per duplicate; same dedupe semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-relay actor (RelayConnection) holding URLSessionWebSocketTask;
dispatcher Task with withTaskGroup spawning one runRelayLoop per relay
in the relaySet() (union of ConnectedClient.relayUrls + primary).
Each runRelayLoop: connect → REQ {kinds:[24133], #p:[user], since:now-60}
→ concurrent receive + heartbeat → on error, exponential backoff (1/2/4/8/16s).
Receive loop currently increments eventsReceived counter without
dispatching to LightSigner — per-event dispatch lands in Task 6.
The 60s `since` lookback catches events published just before
foregrounding (Layer 2's auth_url-triggered burst scenario).
Heartbeat: 30s ping; sendPing callback double-resume guarded with NSLock
to handle URLSessionWebSocketTask's cancel-after-pong race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Each EVENT message acquires an AsyncSemaphore permit (cap 5, the empirical sweet spot from FINDINGS.md) before spawning a child Task that calls LightSigner.handleRequest with a 30s budget enforced via withTaskGroup race against a sleep-then-cancel watchdog. Counters (eventsProcessed, eventsFailed, recentLatenciesMs) update on the MainActor via await MainActor.run blocks. The latency ring buffer caps at 1024 samples for L2 progress UI to read. processEvent is nonisolated since it does its own MainActor hops; this keeps the per-event work off the main thread while updates land cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ption
Root SwiftUI view (MainTabView) observes scenePhase and dispatches
start()/stop() into ForegroundRelaySubscription.shared via
Task { @mainactor in ... }.
2s grace on .inactive (app-switcher peek, control-center swipe) prevents
spurious stop+restart cycles. Confirmed background stops immediately;
returning to active cancels any pending grace.
AppState gains startForegroundSubscription / stopForegroundSubscription
@mainactor methods so non-isolated callers hop in correctly.
project.pbxproj: register ForegroundRelaySubscription.swift in the Clave
target only (NOT NSE — keeps the NSE binary lean within the ~24MB
NSE memory limit; NSE doesn't reference the class).
Fixed: stored-property Self.defaultConcurrency reference inside the
class — replaced with the explicit type name to avoid the covariant Self
diagnostic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>ForegroundRelaySubscription.refreshRelaySet() called from AppState after successful pair or unpair flows. Implementation is stop+restart for v1 (simplest correct); proper add/remove diffing is a follow-up. Restart bounce is ~100ms — acceptable churn given pair/unpair are infrequent operations. Found-and-fixed: ConnectedClient.relayUrls was declared in SharedModels but never populated by any code path. Added SharedStorage.setClientRelayUrls which is now called from AppState.pairClientWithProxy. Without this, Layer 1 would have only ever subscribed to the primary relay, missing nostrconnect-paired clients' URI relays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tests covering: initial-or-post-stop state is idle, no-signer-key sets error, resetCounters zeroes all counters, stop-while-idle is no-op, double-start is idempotent. Real WebSocket I/O is tested in the manual matrix (Task 10) — unit tests cover state-machine boundaries only. Also tightens stop() to set state=.idle synchronously rather than .stopping-then-await-dispatcher-exit. The dispatcher cancellation remains async (it has to be — tasks honor cancellation at suspension points), but the public state machine is now observable-consistent: callers see .idle immediately after stop() returns. This was needed to make tests deterministic when run as a suite (the prior version left state=.stopping briefly between tests, racing setUp's stop()). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For Layer 1 verification matrix (plans/2026-04-26-foreground-relay-subscription.md Task 10): lets a developer add an extra relay to L1's subscription set via the dev menu without going through a fresh nostrconnect pair. Used to point L1 at `nak serve` on Mac LAN during testing. All additions wrapped in #if DEBUG. Release builds (TestFlight, App Store) compile none of this. - SharedStorage: getDebugTestRelay / setDebugTestRelay accessors - ForegroundRelaySubscription.relaySet(): includes the override URL - SettingsView: new "DEBUG: L1 Test Relay" section with status, live counters, relay URL field, Apply+Refresh / Clear buttons Will be deleted before external promotion (alongside the prototype's DebugForegroundSubscription cleanup). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The @State default value used a function call (getDebugTestRelay()) which SwiftUI evaluates each time the View struct is initialized — that caused the field to clear on every view re-creation (e.g. tab switch, observable property change), wiping in-progress edits. Move the hydration into .onAppear with an empty-only check so: - First open: loads any persisted value - Edits persist across re-renders (since onAppear won't overwrite a non-empty draft) - After Apply + leave-and-return: re-loads the saved value DEBUG-only; no production effect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds NSAllowsLocalNetworking to ATS so the iOS app can connect to plain ws:// hosts on the local network (e.g. nak serve on a developer's Mac LAN during the L1 verification matrix). Production traffic still uses wss:// (relay.powr.build); ATS continues to enforce TLS for WAN connections. This exception only relaxes local-network connections. User confirmed plain ws:// worked with the prototype branch under the same Info.plist, so this may have been redundant — but it's a defensive inclusion that doesn't hurt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SwiftUI Form gotcha: two Buttons inside an HStack inside a Section row become one tap target — tapping anywhere in the row fires BOTH button actions. Observed in L1 verification: tapping Apply also fired Clear, so the relay got set then immediately unset, and start() ran with the cleared value (subscribing only to relay.powr.build, missing nak serve). Fix: each button gets its own Form row + .buttonStyle(.borderless) so SwiftUI treats them as independent tap targets. DEBUG-only; no production effect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the dev-menu test relay override and its supporting code. The scaffolding was added for the L1 verification matrix to point ForegroundRelaySubscription at nak serve on Mac LAN; production code doesn't need it, and Info.plist's NSAllowsLocalNetworking ATS relaxation is not #if DEBUG-gateable so it would have shipped to Release. - Clave/Info.plist: drop NSAppTransportSecurity dict - Shared/SharedStorage.swift: drop debugTestRelayKey + accessors - Shared/ForegroundRelaySubscription.swift: drop #if DEBUG branch in relaySet() - Clave/Views/Settings/SettingsView.swift: drop l1TestSection + state Future perf debugging (L2 sprint, Amber comparison) can resurrect via the prototype tag research/fg-sub-prototype-2026-04-26 or a separate test target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DocNR added a commit
that referenced
this pull request
Apr 27, 2026
11 tasks
DocNR added a commit
that referenced
this pull request
Apr 28, 2026
* fix(L1): pending-approval UI refresh + banner notifications Pre-L1, every sign request reached Clave via APNs → NSE, and NSE was the sole producer of "Approve Signing Request" banners. Post-L1 (PR #11), when Clave is foregrounded or in the 2s `.inactive` grace, L1 catches kind:24133 events directly via WebSocket and `SharedStorage.markEventProcessed` marks the event id. NSE then runs from the same APNs push, sees the dedupe, returns `.noEvents`, and produces a silent passive notification — correct, since L1 already handled it. But L1 itself never emitted any user-facing signal: no banner, and the in-process `SharedStorage.queuePendingRequest` write didn't broadcast a refresh signal, so the pending-approvals list only repopulated on tab-switch (HomeView's `.onAppear`) or full restart. Two wires fix both symptoms: 1. Refresh signal (`.pendingRequestsUpdated`) - `SharedStorage.queuePendingRequest` / `removePendingRequest` / `clearPendingRequests` post the notification in-process. - `AppState` observes it and refreshes `pendingRequests` on main. `@Observable` propagates to any subscribed view automatically. - `MainTabView` also refreshes on scenePhase `.active` so cross-process NSE writes (while we were backgrounded) get picked up — the in-process notification doesn't cross the NSE↔main-app boundary. 2. Banner emission (`PendingApprovalBanner`) - New `Shared/PendingApprovalBanner.swift` schedules a `UNNotificationRequest` matching the format NSE already uses for pending pushes (title "Approve Signing Request", `.active` interruption). Identifier is `pending-approval-<requestId>` so approve/deny can `clear()` the delivered banner. - `LightSigner.RequestResult` gains optional `pendingRequestId` so callers can schedule with the same id used in the queued `PendingRequest`. - L1 `ForegroundRelaySubscription.processEvent` schedules on `status == "pending"`. Foreground APNs push handler in `ClaveApp` also schedules (defensive — covers the rare case where NSE didn't process and main-app marked the event first; in normal operation NSE wins the race and this path returns "skipped-duplicate"). - `AppState.approvePendingRequest` / `denyPendingRequest` call `PendingApprovalBanner.clear` so the banner doesn't linger after the user has acted on it. - `ClaveApp.willPresent` now distinguishes: - Locally-scheduled banners (id prefix `pending-approval-`) → display directly, no re-processing. - APNs pushes with non-empty NSE-modified title → display (was being suppressed by unconditional `completionHandler([])`, hiding NSE's own pending banner when the app was foreground). - APNs pushes with empty title (NSE silent-success case) → suppress. NSE's own `deliverContent` `.pending` path is unchanged — it still produces a banner via `contentHandler` for the pure-background case (NSE only, no L1, no foreground push handler). The dedupe ensures only one of {NSE banner, L1-scheduled banner, foreground-handler-scheduled banner} actually fires per event. Verification: - xcodebuild -scheme Clave -destination 'generic/platform=iOS' build → BUILD SUCCEEDED - xcodebuild test on iPhone 17 Pro Max sim (iOS 26.4) → TEST SUCCEEDED - Device test: needs build 25 archive + TestFlight install (next step). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ui): pending-card padding, snapshot protection, ClientDetailView UX Bundled UI/UX improvements going out alongside the L1 pending-approval refresh + banner fix in this PR so they all ride one TestFlight. **1. Pending approvals card edge padding** [Clave/Views/Home/PendingApprovalsView.swift] HomeView wraps it in a Section with `.listRowInsets(EdgeInsets())`, so the card has to self-pad to match its sibling rows (identityBar, statsRow). Added the missing `.padding(.horizontal)` after `.background`. Pre-existing since v1.0 UX sprint, not an L1 regression — only surfaced now because pending approvals weren't refreshing reliably enough for users to notice. **2. App-switcher snapshot protection (audit A10.1)** New `Clave/Views/Components/SnapshotProtected.swift` — ViewModifier that overlays the receiver with an opaque privacy view whenever scenePhase is not `.active`. Applied via `.snapshotProtected()` on the four sheets that render sensitive material: - ExportKeySheet (nsec) - ConnectSheet (bunker URI containing the bunker secret) - QRCodeView (QR code of bunker URI) - ApprovalSheet (incoming approval request, including client identity) iOS captures the app-switcher snapshot during the `.inactive` transition, so a per-sheet overlay is sufficient and avoids blanking the rest of the app on benign control-center swipes. **3. AvatarView prefers name initials over pubkey prefix** [Clave/Views/Components/AvatarView.swift] gains an optional `name: String?` param. When set and non-blank, shows up to two letters derived from the name (e.g. "Joe Bloggs" → "JB"; "Yakihonne" → "YA") in a proportional font. Pubkey-prefix fallback unchanged (monospaced). Gradient remains pubkey-derived so renames don't change the avatar color — only the text inside the circle. Updated four call sites to pass the available name: ClientDetailView header, HomeView clientRow + identityBar, ApprovalSheet. **4. ClientDetailView header + toolbar overhaul** - Tap the client name (or pencil affordance next to it) → existing Rename alert. The bottom-of-screen Rename button is removed. - Toolbar overflow menu (`ellipsis.circle`, top-right) now houses: - Connection Info → opens new `ConnectionInfoSheet` - Rename → same alert as the header tap - Unpair Client (destructive) → existing confirm alert - Bottom `actionsSection` removed entirely. The Unpair button used to live underneath the recent-activity list, which buried it. - New `ConnectionInfoSheet` shows: name, origin URL, npub, hex pubkey (both copyable), trust level, first-connected/last-seen timestamps, total request count, and the relay set the proxy watches for this client (when available via `ConnectedClient.relayUrls`). Verification: - xcodebuild -scheme Clave -destination 'generic/platform=iOS' build → BUILD SUCCEEDED - xcodebuild test on iPhone 17 Pro Max sim (iOS 26.4) → TEST SUCCEEDED - Device test: build 25 archive (next step). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(notifications): sweep blank NC entries on app foreground User reports: blank Notification Center entries accumulating again, much worse with L1 than pre-L1. Cause: every NSE wake for an event L1 already processed returns "skipped-duplicate" → SigningResult(.noEvents) → NSE delivers content with empty title + .passive interruption, then calls removeDeliveredNotifications. The remove is racy — NSE process often exits before iOS commits the notification, so the remove no-ops and the blank entry sticks. L1 amplifies this because most APNs wakes are now "L1 already handled it" duplicates rather than real work for NSE. iOS doesn't expose a "deliver but don't add to NC" hint — `.passive` just suppresses banner+sound, the NC entry is mandatory once the push arrives. So we have to clean up after the fact. Fix: on every scenePhase → .active in MainTabView, query getDeliveredNotifications and remove any with empty title. Locally- scheduled pending-approval banners ("Approve Signing Request"), sign-failure banners ("Signing Failed"), and other real notifications keep their title and are preserved. The main app process lives long enough for the async UNUserNotificationCenter API to actually complete, which the short-lived NSE process does not. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Layer 1 of the foreground RPC acceleration design — an
@MainActor-isolatedForegroundRelaySubscriptionthat holds long-lived WebSocket subscriptions to the user's relays whenever Clave is foregrounded, processing kind:24133 NIP-46 RPCs inline viaLightSigner.handleRequest()instead of waiting on APNs+NSE.Also closes audit item D.1.1 by consolidating the duplicate inline
processedEventIDsdedupe logic fromClaveApp.swiftandNotificationService.swiftinto a singleSharedStorage.markEventProcessed()helper with insertion-ordered ring buffer + 60s age bound.Why
Empirical: bunker mode (APNs+NSE) caps at ~1.6 dec/sec; foreground subscription hits 40.46 dec/sec at conc=5 (verified on real device against
nak serve). For the dev's 7000-DM scenario, that's ~5.8 minutes vs 2.4 hours = ~25× speedup. Justifies the structural change and produces a markedly snappier in-app experience for any NIP-46 client (Wisp, Nostur, Coracle, etc.) — no client-side changes required.What's in this PR
Production code
Shared/ForegroundRelaySubscription.swift(new, ~370 lines):@MainActor @Observableclass. Per-relayURLSessionWebSocketTaskactors.withTaskGroup-based dispatcher matching existing AppState/LightSigner concurrency patterns. Heartbeat (30s ping / 10s pong timeout), exponential reconnect backoff (1/2/4/8/16s).AsyncSemaphorefor per-event concurrency cap (default 5, the empirical sweet spot from FINDINGS). 30s per-event budget enforced viawithTaskGrouprace against a sleep watchdog. Receive loop dispatches events toLightSigner.handleRequestwithresponseRelayUrlthreaded back to the originating relay.Shared/SharedStorage.swift: newmarkEventProcessed(eventId:, createdAt:)helper + newsetClientRelayUrls()/getDebugTestRelay()/setDebugTestRelay()helpers. The dedupe helper is the single choke point both NSE and L1 inherit; the relayUrls setter populatesConnectedClient.relayUrlsat pair time (it was a dead field added in PR feat: proxy-per-client-relay V2 (unblocks external past build 18) #9 V2 but never written by any code path until this PR).Shared/LightSigner.swift: dedupe check at the top ofhandleRequestviamarkEventProcessed. Returnsskipped-duplicatestatus when the event was already processed by another path.Clave/ClaveApp.swift+ClaveNSE/NotificationService.swift: removed redundant inline dedupe blocks (now handled in LightSigner). Net deletion of ~95 lines of duplicated logic.Clave/AppState.swift:startForegroundSubscription/stopForegroundSubscription@MainActorbridge methods.pairClientWithProxynow also callssetClientRelayUrlsand triggersrefreshRelaySet().unpairClientWithProxytriggersrefreshRelaySet().Clave/Views/MainTabView.swift: scenePhase observer with 2s.inactivegrace (cancels pending stop on app-switcher peek / control-center swipe). Confirmed background stops immediately.Clave/Info.plist:NSAllowsLocalNetworkingATS exception so the new WebSocket sub can connect to plainws://hosts on the local network for dev verification (production traffic still useswss://relay.powr.build).Tests
ClaveTests/SharedStorageDedupTests.swift(new, 5 tests): first-seen, repeat, ring buffer cap, age eviction, concurrent within-process serialization.ClaveTests/ForegroundRelaySubscriptionTests.swift(new, 5 tests): initial state, no-signer-key error path, resetCounters, stop-while-idle no-op, double-start idempotent.DEBUG-only test scaffolding (kept in
#if DEBUG)Settings → Developer → DEBUG: L1 Test Relaysection. Lets a developer add an extra relay to L1's subscription set without going through nostrconnect pairing. Used during the L1 verification matrix to point L1 atnak serveon Mac LAN. Status indicator + live counters (Received / Processed / Failed). Apply / Clear buttons (each in its own row to avoid SwiftUI's HStack-tap-target footgun).Production builds (Release) compile none of this DEBUG section.
Empirical evidence
~/hq/clave/research/nip17-bulk-decrypt/FINDINGS.md— full numbers, methodology, comparison tables. Headline:Test plan
xcodebuild -scheme Clave -configuration Debug⇒ BUILD SUCCEEDEDnak serve, throughput 40.46/s, success 100%Set + .suffix(50))Spec / plan / prototype
~/hq/clave/specs/2026-04-26-foreground-bulk-decrypt-design.md~/hq/clave/plans/2026-04-26-foreground-relay-subscription.mdfeat/debug-foreground-subscription(tagresearch/fg-sub-prototype-2026-04-26)Layer 2 (bulk decrypt session UX, conversation-key cache, auth_url heuristic) is a separate follow-up sprint that builds on top of L1 without modifying it.
🤖 Generated with Claude Code