Skip to content

fix: refresh triggers + L1 observability + banner diagnostics (build 28) - #17

Merged
DocNR merged 11 commits into
mainfrom
fix/refresh-triggers-and-l1-observability
Apr 29, 2026
Merged

fix: refresh triggers + L1 observability + banner diagnostics (build 28)#17
DocNR merged 11 commits into
mainfrom
fix/refresh-triggers-and-l1-observability

Conversation

@DocNR

Copy link
Copy Markdown
Owner

Summary

Build 28 bundles three small refresh-trigger fixes with a much larger
diagnostic unlock: three logger categories that have been silently
filtered out of "Copy Recent Logs" since PR #11 / PR #13 are now
included, and a new dev-menu L1 Diagnostics view surfaces
ForegroundRelaySubscription's runtime state alongside iOS notification
permission status.

The previous-session diagnosis "L1 is silently not running" was almost
certainly a visibility artifact: the fg-sub, banner, and nc-sweep
categories were missing from LogExporter.allCategories, so every L1
log line, every banner schedule attempt, and every NC sweep was being
filtered out of the user's exported logs. Adding them is a one-line fix
that makes the next test session immediately diagnostic.

The previous-session prompt also claimed AppState observed
.signingCompleted. Verified by grep: it does not. AppState observes
.pendingRequestsUpdated. The pending-list refresh fix posts BOTH
signals so each subscriber refreshes the data it cares about.

Refresh-trigger fixes

  1. Foreground push handler (ClaveApp.swift handleForegroundSigningRequest):
    posts .pendingRequestsUpdated + .signingCompleted unconditionally
    instead of gating on handledCount > 0. Fixes the user-visible bug
    where the protected-event approve row didn't appear in HomeView while
    Clave was foregrounded — you had to navigate away and back to see it.

  2. L1 processEvent (Shared/ForegroundRelaySubscription.swift):
    posts .signingCompleted after every event regardless of result
    status. Fixes counter staleness (signedToday, per-client requestCount
    badges) when L1 catches a request before NSE.

L1 diagnostic logging

  1. start() silent-return paths (no signer key, no relays) now log
    before returning. Previously set state=.error silently; OSLog had
    no record of why L1 failed to start.
  2. setState(_:message:) helper — single chokepoint that logs every
    transition (idle → starting → listening → reconnecting → ...).
    Replaces scattered direct state = assignments. Lets a tester read
    the full L1 lifecycle from one grep.
  3. start() log expanded to include relay URLs (privacy=.public —
    public WSS endpoints) so a single bad URL poisoning the set is
    visible.

L1 observability surface

  1. New @Observable properties: sessionStartedAt (set on first
    .listening of a dispatcher run, cleared on .idle, spans
    .reconnecting bounces), currentRelays (URLs the run is subscribed
    to).
  2. New Clave/Views/Settings/L1DiagnosticsView.swift — Form-based dev
    menu sub-view (NavigationLink from Settings → Developer, after the
    7-tap unlock). Surfaces:
    • State (color-coded), status message, session age (live timer)
    • eventsReceived / eventsProcessed / eventsFailed
    • Latency p50 + p95 from the existing recentLatenciesMs ring
    • Last error (red, when non-nil)
    • Currently subscribed relays
    • iOS notification permission status — directly addresses the
      "banner doesn't appear" surface. When alert/sound/badge are
      disabled or authorization is denied, the section turns red.
      "Open iOS Notification Settings" deep-links via
      openNotificationSettingsURLString (iOS 16+).
    • Restart L1 + Reset counters buttons.

Banner diagnostic logging

  1. userNotificationCenter willPresent logs its decision in all three
    branches (local-banner show, APNs-non-empty-title show, APNs-empty
    suppress). Combined with the LogExporter fix, the next failed
    banner is end-to-end traceable:
    [fg-sub] processEvent eid=...
    [Banner] Scheduled pending-approval banner client=... kind=...
    [App] willPresent: local banner id=pending-approval-... — show
    
    If any link is missing, that's the actionable break.

Tests

  • 4 new ForegroundRelaySubscriptionTests (sessionStartedAt /
    currentRelays semantics, State.rawValue stability for log greps)
  • 1 new LogExporterFormattingTests regression guard (allCategories
    must include every shipped Logger category — catches the bug class
    this PR fixes)
  • All existing tests pass: xcodebuild test returns TEST SUCCEEDED
    on iPhone 17 Pro Max iOS 26.4

Test plan

Phase A — Local Debug build (no TF needed)

  • Settings → tap Version 7× → Developer section appears
  • Tap "L1 Diagnostics" → state=.listening, session age ticking,
    relays section shows expected URLs (relay.powr.build minimum)
  • Notifications section reflects iOS Settings authorization;
    toggle off in iOS Settings → returns red on next 5s tick
  • "Copy Recent Logs" → pasted text now contains [fg-sub],
    [Banner], [NCSweep] lines
  • Sign auto-approved kind:1 from Coracle on laptop → "Signed Today"
    stat increments without manual refresh (Bug 2 fix)
  • Sign protected kind:0 → log capture shows full banner trace

Phase B — Internal TestFlight (build 28 only)

  • Foreground Clave on Home tab; sign protected event from external
    client via APNs → pending row appears in HomeView immediately
    (no swipe / navigate-away required) — Bug 1 fix, the user-visible
    issue from build 27 testing
  • Background regression: APNs delivers → NSE handles → tap
    notification → foregrounds Clave → pending row appears (PR fix: pending-approval refresh + banner notifications + UI bundle #13
    scenePhase observer; confirm no regression)
  • Restart L1 button cycles state correctly; sessionAge resets

🤖 Generated with Claude Code

DocNRand others added 11 commits April 28, 2026 22:14
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three logger categories were silently filtered out of "Copy Recent Logs":
- fg-sub (ForegroundRelaySubscription) — every L1 lifecycle log invisible
- banner (PendingApprovalBanner) — every banner schedule attempt invisible
- nc-sweep (NotificationCenterSweep) — every NC sweep invisible
This made it appear that L1 was silently not running and that pending-
approval banners were failing without trace. Adding them to allCategories
unblocks diagnosis on the next test session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleForegroundSigningRequest only posted .signingCompleted when
handledCount > 0. When NSE pre-deduped an event (handledCount=0 because
LightSigner returns "skipped-duplicate"), the main app received no
refresh signal even though NSE had already written a pending request to
cross-process storage. Symptom: user gets a protected-event sign request
while Clave is foregrounded, the pending row doesn't appear in HomeView
until they navigate away and back (which fires MainTabView's scenePhase
observer).
Now post both .pendingRequestsUpdated (AppState observes → refreshes
pending list) and .signingCompleted (HomeView/ActivityView observe →
refresh derived counters) unconditionally on every foreground push.
refreshPendingRequests is a single disk read; cost is negligible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After L1 catches and processes a sign request, requestCount and
signedTodayCount get persisted via SharedStorage.touchClient and
logActivity (called inside LightSigner.handleRequest), but HomeView and
ActivityView never re-read those counters. They observe .signingCompleted.
Posting unconditionally (regardless of result.status) covers signed,
skipped-duplicate, pending, and error — all of which represent storage
state that views may want to display. The pending-list refresh path
remains covered by SharedStorage.queuePendingRequest posting
.pendingRequestsUpdated; this fix only addresses the counter-display
gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two start() guards previously set state=.error without logging, so the
OSLog buffer had no record of why L1 failed to start (no-signer-key vs
no-relays vs not-idle). Both paths now log before returning.
Add a setState helper that logs every state transition to [fg-sub] so a
single grep gives a complete lifecycle timeline. Replaces scattered
direct state= assignments in start/stop/runDispatcher/runRelayLoop.
Expand the start log to include relay URLs (privacy=.public — these are
public WSS endpoints) so a single bad URL poisoning the set is visible
in logs. Previously only count was logged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new @observable properties surface the dispatcher's runtime state to
the upcoming L1DiagnosticsView:
- sessionStartedAt: nil when L1 isn't running, set on first transition to
.listening within a dispatcher run, cleared on dispatcher exit. Spans
transient .reconnecting bounces — answers "how long has L1 been alive?"
rather than "how long since the last frame?". Cellular connections
bounce routinely; clearing on every reconnect would make the view
flicker uselessly.
- currentRelays: relay URLs the current run is subscribed to. Empty when
L1 isn't running. Lets the diagnostics view render the actual relay
set so users can spot "L1 connected to wrong relays" without parsing
log output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NavigationLink in Settings → Developer (gated behind 7-tap unlock) opens
a new L1DiagnosticsView that surfaces ForegroundRelaySubscription's
runtime state to a tester without requiring Xcode Console:
- State (color-coded), status message, session age (live timer)
- Counters: received / processed / failed
- Latency p50 + p95 from existing recentLatenciesMs ring buffer
- Last error (red, when non-nil)
- Currently subscribed relays (the actual URLs L1 is hitting)
- iOS notification permission status — directly addresses the "banner
doesn't appear" surface. If alert/sound/badge are disabled or
authorization is denied, the section turns red. "Open iOS Notification
Settings" button uses openNotificationSettingsURLString (iOS 16+).
- Restart L1 + Reset counters buttons for fast manual verification.
Access pattern: @State on ForegroundRelaySubscription.shared. @observable
reference-tracking handles re-renders without environment plumbing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three branches in userNotificationCenter willPresent now log their
decision: local pending-approval banner shown, APNs push with non-empty
title shown, APNs push with empty title suppressed.
Combined with the LogExporter fix surfacing [Banner] schedule logs and
[fg-sub] processEvent logs, this gives a complete user-visible-banner
trace. A failed banner can now be diagnosed end-to-end:
[fg-sub] processEvent eid=...
[Banner] Scheduled pending-approval banner client=... kind=...
[App] willPresent: local banner id=pending-approval-... — show
If any link is missing, that's the actionable break.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rift
ForegroundRelaySubscriptionTests:
- sessionStartedAt nil at idle / on bail / not cleared by resetCounters
- currentRelays empty at idle
- State.rawValue stability for log greps (renames break the test, not
user log analysis silently)
LogExporterFormattingTests:
- Regression guard: allCategories must include every shipped Logger
category declaration. Catches the class of bug where a new feature
ships its own Logger but forgets to update the export filter, leaving
the user blind to that subsystem's activity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- import Combine for Timer.publish().autoconnect()
- explicit Color type on the authorization-status ternary so the
ShapeStyle inference picks the right overload
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users who imported their key in an older build had an empty
signerPubkeyHexKey in app-group UserDefaults — only importKey() and
generateKey() ever wrote this cache, while loadState() (the
read-existing-key path) populated only the in-memory AppState property.
L1's start() reads from UserDefaults (not AppState directly, since L1
runs on its own MainActor and is owned by the singleton, not AppState),
so for affected users L1 saw "no signer key configured" and bailed
silently. NSE was unaffected — it loads the nsec from Keychain on each
wake. Symptom: pending events flowed through APNs+NSE, but L1 never
ran, and the foreground push handler was the only main-process refresh
path (which Bug 1 in this same PR also fixed).
loadState now writes the derived pubkey hex back to UserDefaults if the
cached value is stale or empty. One-time backfill per install; the cache
is correct forever after.
pbxproj 28 → 29 since build 28 is already in TestFlight internal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DocNR
DocNR merged commit a5fde66 into mainApr 29, 2026
@DocNR
DocNR deleted the fix/refresh-triggers-and-l1-observability branch April 29, 2026 03:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@DocNR