Skip to content

fix(register): auto re-register with proxy on every launch#15

Merged
DocNR merged 1 commit into
mainfrom
fix/auto-reregister-on-launch
Apr 28, 2026
Merged

fix(register): auto re-register with proxy on every launch#15
DocNR merged 1 commit into
mainfrom
fix/auto-reregister-on-launch

Conversation

@DocNR
Copy link
Copy Markdown
Owner

@DocNR DocNR commented Apr 28, 2026

Summary

Real tester (npub1whale...) hit a silent push-delivery failure today: their signer pubkey had zero APNs tokens registered with the proxy (`[PRIMARY] Event for pubkey 75fbfcd7... — no registered tokens, skipping` repeated 63 times in 4h). Foreground L1 worked because it catches via WebSocket and bypasses APNs entirely; everything else silently failed. Their workaround was "toggle Clave to foreground every time" — fine as a stopgap, broken as a product.

Root cause: registration with the proxy only happens in `AppState.importKey()` / `generateKey()` and the manual Settings → Register button. Three real failure modes leave the proxy with a stale or absent token:

  1. iOS rotates the device token (Apple does this periodically, especially after iOS upgrades). New token from `didRegisterForRemoteNotificationsWithDeviceToken` was being saved to UserDefaults but never pushed to the proxy.
  2. User reinstalls Clave from TestFlight — fresh APNs token, but the existing nsec in Keychain means we never re-hit the importKey/generateKey path.
  3. Proxy-side token loss (the 2026-04-20 tokens.json migration wiped legacy entries; future bugs may do similar) — no recovery without manual Settings tap.

The previous design comment in `ClaveApp.swift` claimed AppDelegate "doesn't have access to the signer nsec without duplicating LightEvent.signNip98 logic" — true, but solvable by signaling AppState via NotificationCenter and letting it do the actual NIP-98-signed POST.

Changes

  • `Clave/ClaveApp.swift` `didRegisterForRemoteNotificationsWithDeviceToken` now posts `.apnsDeviceTokenAvailable` every time iOS hands us a token. New Notification.Name added to the existing extension.
  • `Clave/AppState.swift`:
    • `init` adds an observer for `.apnsDeviceTokenAvailable` that updates `deviceToken` and calls `registerWithProxy()` if `isKeyImported`.
    • `loadState()` calls `registerWithProxy()` if both key and stored token are present. Belt-and-suspenders for the cold-launch ordering case where iOS handed us the token via the AppDelegate path before `loadState()` ran (so the observer's `isKeyImported` check failed at that moment because the key hadn't loaded from Keychain yet).

Both paths are idempotent on the proxy side (the /register endpoint upserts), so the common case of "token+pubkey unchanged" is harmless.

Test plan

  • `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 26 TestFlight) — reproduce the tester's symptom + verify recovery:
    • On a phone with no current token entry on the proxy (force-removable via `sg clave-proxy -c 'cat /opt/clave-proxy/tokens.json' | jq` to confirm), launch build 26.
    • Within ~1s of launch, check `curl https://proxy.clave.casa/health | jq .total_tokens` — should increment by 1.
    • Send a sign request from a paired client with Clave force-closed → NSE wakes → request signs.
  • Real tester confirmation: ship build 26 → ask npub1whale... to install → check whether their 75fbfcd7... pubkey appears in tokens.json without them tapping Settings → Register.

Closes

BACKLOG: "Auto-register on launch (low priority): loadState() doesn't re-register with the proxy — only importKey()/generateKey() do." Promoted to High after this incident.

Why this didn't make build 25

Build 25 just shipped (PR #13 merged + archived). This is a follow-up for build 26. Independent enough to merge separately; no conflicts with PR #14 (proxy resilience, server-side).

🤖 Generated with Claude Code

Real tester (npub1whale...) hit a silent push-delivery failure today:
their signer pubkey had ZERO APNs tokens registered with the proxy
(`[PRIMARY] Event for pubkey 75fbfcd7... — no registered tokens,
skipping` repeated 63 times in 4h). Foreground L1 worked because it
catches via WebSocket and bypasses APNs entirely; everything else
silently failed.

Root cause: registration with the proxy only happens in
`AppState.importKey()` / `generateKey()` and the manual Settings →
Register button. Three real failure modes lead to a stale or absent
token on the proxy:

1. iOS rotates the device token (Apple does this periodically,
   especially after iOS upgrades). New token from
   `didRegisterForRemoteNotificationsWithDeviceToken` was being saved
   to UserDefaults but never pushed to the proxy.
2. User reinstalls Clave from TestFlight — fresh APNs token, but the
   existing nsec in Keychain means we never re-hit the
   importKey/generateKey path.
3. Proxy-side token loss (the 2026-04-20 tokens.json migration wiped
   legacy entries; future bugs may do similar) — we had no way to
   recover without manual intervention.

The previous design comment in `ClaveApp.swift` claimed AppDelegate
"doesn't have access to the signer nsec without duplicating
LightEvent.signNip98 logic" — true, but solvable by signaling AppState
via NotificationCenter and letting it do the actual NIP-98-signed POST.

Changes:

* `Clave/ClaveApp.swift` `didRegisterForRemoteNotificationsWithDeviceToken`
  now posts `NotificationCenter.default.post(name: .apnsDeviceTokenAvailable, object: token)`
  every time iOS hands us a token. New `.apnsDeviceTokenAvailable`
  Notification.Name added to the existing extension.

* `Clave/AppState.swift`:
  - `init` adds a NotificationCenter observer for
    `.apnsDeviceTokenAvailable` that updates `deviceToken` and calls
    `registerWithProxy()` if `isKeyImported`.
  - `loadState()` calls `registerWithProxy()` if both key and stored
    token are present. Belt-and-suspenders for the cold-launch ordering
    case where iOS handed us the token via the AppDelegate path *before*
    `loadState()` ran (so the observer's `isKeyImported` check failed
    at that moment because the key hadn't loaded from Keychain yet).

Both paths are idempotent on the proxy side (the /register endpoint
upserts), so the common case of "token+pubkey unchanged" is harmless.

Verification:
- xcodebuild -scheme Clave -destination 'generic/platform=iOS' build →
  BUILD SUCCEEDED
- xcodebuild test → TEST SUCCEEDED
- Real tester confirmation pending: build 26 ships → tester reinstalls
  or just opens app → proxy tokens.json gains their entry without manual
  Settings → Register tap.

Closes the BACKLOG item: "Auto-register on launch: loadState() doesn't
re-register with the proxy — only importKey()/generateKey() do."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DocNR DocNR force-pushed the fix/auto-reregister-on-launch branch from b7c5fa5 to f60f873 Compare April 28, 2026 13:38
@DocNR DocNR merged commit 520d7f2 into main Apr 28, 2026
@DocNR DocNR deleted the fix/auto-reregister-on-launch branch April 28, 2026 13:39
DocNR added a commit that referenced this pull request Apr 28, 2026
Rolls up PR #13 (pending-approval refresh + banner + UI bundle, already
shipped as build 25 from the branch) and PR #15 (auto re-register with
proxy on every launch). Build 25 was archived from PR #13 branch
without merging to main; this commit catches main up to where build 25
already was, plus adds PR #15 on top for build 26.

Tagging policy: tag v0.1.0-build26 immediately after archive as
Pre-release on GitHub; flip to Latest once Apple clears external review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DocNR added a commit that referenced this pull request Apr 28, 2026
…C sweep (#16)

Two real bugs from build 26 testing today:

1. **registerWithProxy silently drops failures on bad cellular.** User
   launched Clave on weak cellular signal; the auto-register HTTP POST
   (10s timeout) failed, the failure was dropped because the auto-
   register call sites in `AppState.init` (`.apnsDeviceTokenAvailable`
   observer) and `loadState()` pass `completion: nil`. Token never
   reached proxy. Symptom: signing silently failed until user moved to
   wifi and tapped Settings → Register manually (the manual path
   surfaces the failure to the UI). PR #15 was correct on the happy
   path but didn't self-heal when the initial POST failed.

2. **Blank Notification Center entries weren't being swept.** Build 26
   shipped `MainTabView.sweepBlankNotifications()` filtering on
   `title.isEmpty`. But the proxy's APNs payload sets
   `alert: { title: " ", body: " " }` (single SPACE characters, so NSE
   has something to override). When NSE doesn't run (cold-launch race,
   timeout, force-quit recovery), iOS keeps the proxy's original
   payload — title is " " (single space), NOT empty. Sweep never
   matched these. User reported 8 blank entries accumulating in NC
   while Clave was backgrounded.

Changes:

* New `Clave/Views/Components/NotificationCenterSweep.swift` extracts
  `sweepBlankNotifications()` to a top-level free function so both
  `MainTabView` (scenePhase observer) and `ForegroundRelaySubscription`
  (L1 event dispatch) can call it. Filter now trims whitespace and
  checks BOTH title AND body, catching the proxy single-space fallback.

* `MainTabView.handleScenePhase` now sweeps on `.inactive` too (catches
  the case where a user opens NC via swipe-down while Clave is the
  most-recent foreground app but isn't currently `.active`). Also calls
  `appState.ensureRegisteredFresh()` on `.active`.

* `Shared/ForegroundRelaySubscription.swift` calls
  `sweepBlankNotifications()` after each in-process event. Catches the
  case where Clave is foregrounded and a parallel APNs push leaves a
  blank NC entry from NSE's `.noEvents` return path.

* `Clave/AppState.swift` `registerWithProxy()` now records
  `lastRegisterSucceededAtKey` / `lastRegisterFailedAtKey` timestamps
  in `SharedConstants.sharedDefaults`. New `ensureRegisteredFresh()`
  method gates re-registers: skips if last success < 30 min ago,
  applies a 60s cooldown after failures so a dead proxy doesn't get
  hammered on every foreground.

* `Shared/SharedConstants.swift` adds the two new defaults keys.

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 27): on bad cellular, launch Clave, verify
  ensureRegisteredFresh() retries on next foreground after network
  recovery. Verify NC stays clean of blank entries even with Clave
  backgrounded for ~30 min during a session.

Closes BACKLOG: "registerWithProxy() retry on transient network failure"
(opened today, fixed same day).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to 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