eleven / messenger Public

APNs push notifications: ciphertext-only payloads, on-device decrypt in a notification extension (#64)

* server+ios: APNs push notifications with on-device decryption (E2EE-preserving)

Server: apns.go — HTTP/2 + ES256 provider-JWT sender, configured entirely by
LCHAT_APNS_* env (a no-op when unset), apns_tokens table + token-gated
/api/apns/subscribe|unsubscribe, fan-out hooked into pushNewMessage under the
same suppression rules as Web Push. The payload carries ONLY the ciphertext
blob + thread id.

App: PushManager registers + posts the APNs token to every joined instance
(permission requested the moment the first chat is added), suppresses banners
for the on-screen chat, and deep-links notification taps to their chat.
ThreadStore parks each thread's key + title in the shared keychain
(SharedThreadKeys); the LChatNSE notification extension decrypts the pushed
blob on-device and rewrites the banner — plaintext never leaves the phone.

Verified: TestAPNsSubscribe, the PushBlobDecryptTest cross-implementation gate
(crypto.js blob → GroupKey), permission + banner delivery + full UI regression
in the simulator. The simulator does NOT run NSEs for simctl push, so the
banner rewrite itself is a device checkbox behind the paid-signing go-live
steps documented in ios/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+deploy: push entitlements (aps-environment + shared keychain group) and APNs env forwarding in deploy-instance.sh

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b4c7f95b349369364fa425751e62b249c35ca309 parent 84ee997
21 files changed, +830 −3
  • apns.go +265 −0
  • changelog/2026-07-02-ios-push-notifications.md +4 −0
  • db.go +9 −0
  • deploy-instance.sh +7 −1
  • handlers_test.go +58 −0
  • ios/LChat/LChat.entitlements +10 −0
  • ios/LChat/LChatApp.swift +22 −0
  • ios/LChat/PushManager.swift +98 −0
  • ios/LChat/SharedThreadKeys.swift +70 −0
  • ios/LChat/ThreadStore.swift +5 −0
  • ios/LChat/Views/ThreadListView.swift +7 −1
  • ios/LChatNSE/Info.plist +31 −0
  • ios/LChatNSE/LChatNSE.entitlements +12 −0
  • ios/LChatNSE/NotificationService.swift +47 −0
  • ios/LChatTests/PushBlobDecryptTest.swift +52 −0
  • ios/LChatTests/push-blob-vector.json +6 −0
  • ios/LChatUITests/JoinAndChatTests.swift +14 −0
  • ios/README.md +29 −0
  • ios/project.yml +28 −1
  • main.go +53 −0
  • push.go +3 −0
diff --git a/apns.go b/apns.go
new file mode 100644
index 0000000..4fd0001
--- /dev/null
+++ b/apns.go
@@ -0,0 +1,265 @@
+package main
+
+import (
+ "bytes"
+ "crypto/ecdsa"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+)
+
+// APNs support for the native iOS app ("Eleven"). Mirrors the Web Push path:
+// the payload carries only the ciphertext blob + thread id — the app's
+// Notification Service Extension decrypts on-device with keys from the
+// Keychain, so the E2EE rule holds end to end (Apple relays ciphertext).
+//
+// Configured entirely by env; with no key configured the whole transport is a
+// no-op, so instances without an APNs key behave exactly as before.
+//
+// LCHAT_APNS_KEY — the .p8 auth key (PEM), or base64 of it
+// LCHAT_APNS_KEY_ID — the 10-char key id
+// LCHAT_APNS_TEAM_ID — the Apple team id
+// LCHAT_APNS_TOPIC — the app's bundle id
+//
+// Tokens are stored per device-session token (like Web Push subscriptions), so
+// the same sender/active-user suppression rules apply. A token registered from
+// a debug build sets sandbox=1 and is delivered via Apple's sandbox gateway.
+
+// APNsToken is one registered iOS device, tied to the session token that made it.
+type APNsToken struct {
+ APNsToken string
+ Token string
+ Sandbox bool
+}
+
+func (db *DB) addAPNsToken(t APNsToken) error {
+ sandbox := 0
+ if t.Sandbox {
+ sandbox = 1
+ }
+ _, err := db.sql.Exec(
+ `INSERT INTO apns_tokens(apns_token, token, sandbox, created_at)
+ VALUES(?, ?, ?, ?)
+ ON CONFLICT(apns_token) DO UPDATE SET token=excluded.token, sandbox=excluded.sandbox`,
+ t.APNsToken, t.Token, sandbox, time.Now().Unix(),
+ )
+ return err
+}
+
+func (db *DB) listAPNsTokens() ([]APNsToken, error) {
+ rows, err := db.sql.Query("SELECT apns_token, token, sandbox FROM apns_tokens")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []APNsToken{}
+ for rows.Next() {
+ var t APNsToken
+ var sandbox int
+ if err := rows.Scan(&t.APNsToken, &t.Token, &sandbox); err != nil {
+ return nil, err
+ }
+ t.Sandbox = sandbox == 1
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+func (db *DB) deleteAPNsToken(apnsToken string) error {
+ _, err := db.sql.Exec("DELETE FROM apns_tokens WHERE apns_token = ?", apnsToken)
+ return err
+}
+
+func (db *DB) deleteAPNsTokensForToken(token string) error {
+ _, err := db.sql.Exec("DELETE FROM apns_tokens WHERE token = ?", token)
+ return err
+}
+
+// apnsClient signs and sends. nil when unconfigured (transport disabled).
+type apnsClient struct {
+ key *ecdsa.PrivateKey
+ keyID string
+ teamID string
+ topic string
+ http *http.Client
+
+ mu sync.Mutex
+ jwt string
+ jwtAge time.Time
+}
+
+// newAPNsClient reads the env config; returns nil (and logs why) when absent.
+func newAPNsClient() *apnsClient {
+ raw := os.Getenv("LCHAT_APNS_KEY")
+ keyID := os.Getenv("LCHAT_APNS_KEY_ID")
+ teamID := os.Getenv("LCHAT_APNS_TEAM_ID")
+ topic := os.Getenv("LCHAT_APNS_TOPIC")
+ if raw == "" || keyID == "" || teamID == "" || topic == "" {
+ return nil // not configured — Web Push only
+ }
+ if !strings.Contains(raw, "BEGIN") { // base64-packed PEM (easier to pass as env)
+ if dec, err := base64.StdEncoding.DecodeString(raw); err == nil {
+ raw = string(dec)
+ }
+ }
+ block, _ := pem.Decode([]byte(raw))
+ if block == nil {
+ log.Println("apns: LCHAT_APNS_KEY is not PEM; APNs disabled")
+ return nil
+ }
+ parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ log.Println("apns: bad key:", err, "— APNs disabled")
+ return nil
+ }
+ ec, ok := parsed.(*ecdsa.PrivateKey)
+ if !ok {
+ log.Println("apns: key is not ECDSA; APNs disabled")
+ return nil
+ }
+ log.Printf("apns: configured (team %s, key %s, topic %s)", teamID, keyID, topic)
+ return &apnsClient{
+ key: ec, keyID: keyID, teamID: teamID, topic: topic,
+ http: &http.Client{Timeout: 15 * time.Second},
+ }
+}
+
+// bearer returns a cached provider JWT, re-signed when older than ~50 minutes
+// (Apple accepts 20–60; a stale token gets ExpiredProviderToken).
+func (c *apnsClient) bearer() (string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.jwt != "" && time.Since(c.jwtAge) < 50*time.Minute {
+ return c.jwt, nil
+ }
+ b64 := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
+ header, _ := json.Marshal(map[string]string{"alg": "ES256", "kid": c.keyID})
+ claims, _ := json.Marshal(map[string]any{"iss": c.teamID, "iat": time.Now().Unix()})
+ signing := b64(header) + "." + b64(claims)
+ digest := sha256.Sum256([]byte(signing))
+ r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:])
+ if err != nil {
+ return "", err
+ }
+ // JOSE wants the raw fixed-width r||s, not ASN.1.
+ sig := make([]byte, 64)
+ r.FillBytes(sig[:32])
+ s.FillBytes(sig[32:])
+ c.jwt = signing + "." + b64(sig)
+ c.jwtAge = time.Now()
+ return c.jwt, nil
+}
+
+var errAPNsGone = errors.New("apns: token gone")
+
+// send posts one alert to one device. mutable-content lets the app's
+// notification extension decrypt and rewrite the placeholder text.
+func (c *apnsClient) send(deviceToken string, sandbox bool, threadID, blob string) error {
+ payload, _ := json.Marshal(map[string]any{
+ "aps": map[string]any{
+ "alert": map[string]string{"title": "New message"},
+ "mutable-content": 1,
+ "sound": "default",
+ "thread-id": threadID,
+ },
+ // Same keys as the Web Push payload (sw.js) — the NSE mirrors it.
+ "thread": threadID,
+ "blob": blob,
+ })
+ host := "https://api.push.apple.com"
+ if sandbox {
+ host = "https://api.sandbox.push.apple.com"
+ }
+ req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ token, err := c.bearer()
+ if err != nil {
+ return err
+ }
+ req.Header.Set("authorization", "bearer "+token)
+ req.Header.Set("apns-topic", c.topic)
+ req.Header.Set("apns-push-type", "alert")
+ req.Header.Set("apns-priority", "10")
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return nil
+ }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
+ if resp.StatusCode == http.StatusGone || bytes.Contains(body, []byte("BadDeviceToken")) {
+ return errAPNsGone
+ }
+ return fmt.Errorf("apns: status %d body=%q", resp.StatusCode, body)
+}
+
+// apnsFanOut applies the selectRecipients rules to APNs tokens (keep the filter
+// in lockstep with push.go's selectRecipients) and sends concurrently. No-op
+// when APNs isn't configured.
+func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool, recipients map[string]bool) int {
+ if s.apns == nil {
+ return 0
+ }
+ tokens, err := s.db.listAPNsTokens()
+ if err != nil || len(tokens) == 0 {
+ return 0
+ }
+ senderUser := ""
+ if exceptToken != "" {
+ senderUser, _ = s.db.deviceTokenUser(exceptToken)
+ }
+ sem := make(chan struct{}, maxPushConcurrency)
+ var wg sync.WaitGroup
+ sent := 0
+ var mu sync.Mutex
+ for _, t := range tokens {
+ if exceptToken != "" && t.Token == exceptToken {
+ continue
+ }
+ uid, known := s.db.deviceTokenUser(t.Token)
+ if senderUser != "" && known && uid == senderUser {
+ continue
+ }
+ if skipActive && s.isUserActive(uid) {
+ continue
+ }
+ if recipients != nil && (!known || !recipients[uid]) {
+ continue
+ }
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(t APNsToken) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ err := s.apns.send(t.APNsToken, t.Sandbox, threadID, blob)
+ switch {
+ case err == nil:
+ mu.Lock()
+ sent++
+ mu.Unlock()
+ case errors.Is(err, errAPNsGone):
+ _ = s.db.deleteAPNsToken(t.APNsToken)
+ default:
+ log.Println("apns:", err)
+ }
+ }(t)
+ }
+ wg.Wait()
+ return sent
+}
diff --git a/changelog/2026-07-02-ios-push-notifications.md b/changelog/2026-07-02-ios-push-notifications.md
new file mode 100644
index 0000000..13ef384
--- /dev/null
+++ b/changelog/2026-07-02-ios-push-notifications.md
@@ -0,0 +1,4 @@
+Groundwork for iPhone notifications: the server can now tap Apple's push
+service to wake the app when a message arrives — and just like on the web,
+what travels is encrypted; your iPhone decrypts the preview itself. Rolling
+out to devices soon.
diff --git a/db.go b/db.go
index 9a21e7c..aa337d7 100644
--- a/db.go
+++ b/db.go
@@ -100,6 +100,12 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
auth TEXT NOT NULL,
created_at INTEGER NOT NULL
);
+CREATE TABLE IF NOT EXISTS apns_tokens (
+ apns_token TEXT PRIMARY KEY,
+ token TEXT NOT NULL,
+ sandbox INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL
+);
CREATE TABLE IF NOT EXISTS reactions (
message_id INTEGER NOT NULL,
token TEXT NOT NULL,
@@ -798,6 +804,9 @@ func (db *DB) removeUser(id string) error {
}{
{"DELETE FROM thread_keys WHERE user_id = ?", []any{id}},
{"DELETE FROM thread_members WHERE user_id = ?", []any{id}},
+ // APNs tokens hang off device-session tokens — resolve them BEFORE the
+ // device_tokens rows below are gone.
+ {"DELETE FROM apns_tokens WHERE token IN (SELECT token FROM device_tokens WHERE user_id = ?)", []any{id}},
{"DELETE FROM device_tokens WHERE user_id = ?", []any{id}},
{"DELETE FROM key_invites WHERE user_id = ?", []any{id}},
{"DELETE FROM instance_keys WHERE user_id = ?", []any{id}},
diff --git a/deploy-instance.sh b/deploy-instance.sh
index f2509c9..5bed176 100755
--- a/deploy-instance.sh
+++ b/deploy-instance.sh
@@ -27,8 +27,14 @@ sed -e "s/^name = \"lchat\"\$/name = \"$INSTANCE\"/" \
"$REPO/.miren/app.toml" > "$TMP/.miren/app.toml"
echo "Deploying '$INSTANCE' (disk: ${INSTANCE}-data, host: $HOST, cluster: $CLUSTER)"
+# APNs for the native app: forward the LCHAT_APNS_* vars when the caller has
+# them set (the transport is a no-op on instances deployed without them).
+EXTRA=()
+for v in LCHAT_APNS_KEY LCHAT_APNS_KEY_ID LCHAT_APNS_TEAM_ID LCHAT_APNS_TOPIC; do
+ [ -n "${!v:-}" ] && EXTRA+=(-e "$v=${!v}")
+done
miren deploy -d "$TMP" -C "$CLUSTER" -f \
- -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN"
+ -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN" ${EXTRA[@]+"${EXTRA[@]}"}
miren route set "$HOST" "$INSTANCE" -C "$CLUSTER"
URL="https://$HOST/admin#token=$TOKEN"
diff --git a/handlers_test.go b/handlers_test.go
index ba5c5e3..d08cea8 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -760,3 +760,61 @@ func joinedMember(t *testing.T, srv *Server, label string) (token, userID, threa
}
return tok, uid, tid
}
+
+// APNs registration is token-gated and idempotent; unsubscribe removes the row.
+func TestAPNsSubscribe(t *testing.T) {
+ ts, s := newTestServer(t)
+
+ // Mint a member the same way TestKeyInviteFlow does: admin key-invite + claim.
+ adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Pat","id":"apns-invite","wrapped":""}`)
+ r, _ := http.Post(ts.URL+"/api/key-invite/apns-invite/claim", "application/json",
+ strings.NewReader(`{"label":"Pat"}`))
+ var claim struct {
+ Ok bool `json:"ok"`
+ Token string `json:"token"`
+ }
+ json.NewDecoder(r.Body).Decode(&claim)
+ if !claim.Ok || claim.Token == "" {
+ t.Fatalf("claim: %+v", claim)
+ }
+ post := func(path, body string) *http.Response {
+ resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp
+ }
+
+ // Bad token refused.
+ if resp := post("/api/apns/subscribe", `{"token":"nope","apns_token":"abc"}`); resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("bad token: want 403, got %d", resp.StatusCode)
+ }
+
+ // Valid registration lands; enabled=false without LCHAT_APNS_* configured.
+ resp := post("/api/apns/subscribe", `{"token":"`+claim.Token+`","apns_token":"abc","sandbox":true}`)
+ var out struct {
+ Ok bool `json:"ok"`
+ Enabled bool `json:"enabled"`
+ }
+ json.NewDecoder(resp.Body).Decode(&out)
+ if !out.Ok || out.Enabled {
+ t.Fatalf("subscribe: %+v", out)
+ }
+ rows, err := s.db.listAPNsTokens()
+ if err != nil || len(rows) != 1 || rows[0].APNsToken != "abc" || !rows[0].Sandbox || rows[0].Token != claim.Token {
+ t.Fatalf("stored: %v %v", rows, err)
+ }
+
+ // Re-registering the same APNs token updates in place, never duplicates.
+ post("/api/apns/subscribe", `{"token":"`+claim.Token+`","apns_token":"abc"}`)
+ if rows, _ = s.db.listAPNsTokens(); len(rows) != 1 || rows[0].Sandbox {
+ t.Fatalf("upsert: %v", rows)
+ }
+
+ if resp := post("/api/apns/unsubscribe", `{"token":"`+claim.Token+`","apns_token":"abc"}`); resp.StatusCode != http.StatusOK {
+ t.Fatalf("unsubscribe: %d", resp.StatusCode)
+ }
+ if rows, _ = s.db.listAPNsTokens(); len(rows) != 0 {
+ t.Fatalf("not removed: %v", rows)
+ }
+}
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements
index d218dc7..15eaa4e 100644
--- a/ios/LChat/LChat.entitlements
+++ b/ios/LChat/LChat.entitlements
@@ -12,5 +12,15 @@
<!-- Lets the sandboxed Mac build read files chosen via the file picker. -->
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
+ <!-- Push + the keychain group shared with the notification extension (it
+ decrypts pushed ciphertext with keys the app parks there). Both need a
+ paid team; free/ad-hoc signing ignores or rejects them — see the
+ go-live notes in ios/README.md. -->
+ <key>aps-environment</key>
+ <string>development</string>
+ <key>keychain-access-groups</key>
+ <array>
+ <string>$(AppIdentifierPrefix)com.rushedsunlight.eleven.shared</string>
+ </array>
</dict>
</plist>
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 3492ea8..8d7ec5c 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -2,6 +2,7 @@ import SwiftUI
@main
struct LChatApp: App {
+ @UIApplicationDelegateAdaptor(PushManager.self) private var push
@StateObject private var app = AppState()
var body: some Scene {
@@ -69,6 +70,7 @@ final class NavModel: ObservableObject {
// its feed.
struct RootView: View {
@EnvironmentObject var app: AppState
+ @EnvironmentObject private var push: PushManager
@StateObject private var nav = NavModel()
@State private var path: [String] = [] // pushed thread ids
@State private var showServers = false
@@ -90,12 +92,32 @@ struct RootView: View {
}
.onAppear {
if nav.instanceID == nil { nav.selectInstance(app.instances.first) }
+ // Someone with chats wants to hear about them (no-op without the
+ // push entitlement; asks permission the first time).
+ if !app.instances.isEmpty { push.enablePush() }
+ }
+ // A tapped notification lands in its chat: the shared key store knows
+ // which instance the thread belongs to.
+ .onReceive(NotificationCenter.default.publisher(for: PushManager.openThread)) { note in
+ guard let threadID = note.object as? String else { return }
+ if let entry = SharedThreadKeys.lookup(threadID: threadID),
+ let inst = app.instances.first(where: { $0.origin == entry.origin }) {
+ if nav.instanceID != inst.id {
+ path = []
+ nav.selectInstance(inst)
+ }
+ if path.last != threadID { path.append(threadID) }
+ }
}
}
private func select(_ inst: SavedInstance) {
path = [] // pushed chats belong to the previous server
nav.selectInstance(inst)
+ // First join included: ask for notification permission the moment there's
+ // a chat worth hearing about, and (re)register this instance.
+ push.enablePush()
+ push.subscribeNewInstance(inst)
withAnimation(.easeOut(duration: 0.2)) { showServers = false }
}
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift
new file mode 100644
index 0000000..715bf7c
--- /dev/null
+++ b/ios/LChat/PushManager.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+import UserNotifications
+
+// APNs plumbing for the shell. The server relays only ciphertext (see apns.go);
+// the notification extension (LChatNSE) decrypts locally, so this file is just
+// registration + routing:
+// - ask permission and register once the user has a chat
+// - post the APNs token to EVERY joined instance (each server pushes its own)
+// - suppress banners for the chat that's open on-screen
+// - tapping a notification deep-links to its chat
+//
+// Note: receiving real pushes needs the aps-environment entitlement (paid team,
+// see ios/README.md go-live notes); registration silently no-ops without it.
+final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
+ static let openThread = Notification.Name("lchat.openThread")
+ private static let tokenKey = "lchat.apnsToken" // last token, for re-subscribing new instances
+
+ func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
+ UNUserNotificationCenter.current().delegate = self
+ return true
+ }
+
+ /// Ask for permission (first time) and (re)register. Call whenever the app
+ /// has at least one instance — repeat calls are cheap and idempotent.
+ func enablePush() {
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
+ guard granted else { return }
+ DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() }
+ }
+ }
+
+ func application(_ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
+ UserDefaults.standard.set(hex, forKey: Self.tokenKey)
+ subscribeAll(apnsToken: hex)
+ }
+
+ func application(_ application: UIApplication,
+ didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ // Expected on dev builds without the push entitlement — nothing to do.
+ }
+
+ /// Push the stored APNs token to an instance that was just added (the
+ /// registration callback only fires once per token).
+ func subscribeNewInstance(_ inst: SavedInstance) {
+ guard let hex = UserDefaults.standard.string(forKey: Self.tokenKey) else { return }
+ subscribe(inst, apnsToken: hex)
+ }
+
+ private func subscribeAll(apnsToken: String) {
+ for inst in InstancesStore.load() { subscribe(inst, apnsToken: apnsToken) }
+ }
+
+ private func subscribe(_ inst: SavedInstance, apnsToken: String) {
+ guard let base = inst.baseURL else { return }
+ #if DEBUG
+ let sandbox = true // Xcode/dev builds get APNs sandbox tokens
+ #else
+ let sandbox = false
+ #endif
+ var req = URLRequest(url: base.appendingPathComponent("api/apns/subscribe"))
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try? JSONSerialization.data(withJSONObject: [
+ "token": inst.deviceToken, "apns_token": apnsToken, "sandbox": sandbox,
+ ])
+ URLSession.shared.dataTask(with: req).resume() // best-effort
+ }
+
+ // MARK: Presentation + taps
+
+ // Which chat is on-screen right now (set by the pushed ChatView) — a banner
+ // for the conversation you're reading is just noise.
+ static var activeThreadID: String?
+
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
+ let thread = notification.request.content.userInfo["thread"] as? String
+ if let thread, thread == Self.activeThreadID,
+ UIApplication.shared.applicationState == .active {
+ completionHandler([])
+ return
+ }
+ completionHandler([.banner, .sound])
+ }
+
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void) {
+ if let thread = response.notification.request.content.userInfo["thread"] as? String {
+ NotificationCenter.default.post(name: Self.openThread, object: thread)
+ }
+ completionHandler()
+ }
+}
diff --git a/ios/LChat/SharedThreadKeys.swift b/ios/LChat/SharedThreadKeys.swift
new file mode 100644
index 0000000..5afa92b
--- /dev/null
+++ b/ios/LChat/SharedThreadKeys.swift
@@ -0,0 +1,70 @@
+import Foundation
+import Security
+
+// Thread keys + display titles shared with the notification extension: the APNs
+// payload is ciphertext (the server never sees plaintext), so the NSE decrypts
+// on-device with the key stored here. The app refreshes entries whenever a
+// ThreadStore loads; the NSE only reads.
+//
+// Storage is the keychain (they're message keys), accessible after first unlock
+// so a notification arriving while the phone is locked can still be decrypted.
+// On properly-signed builds both targets opt into the same keychain access
+// group; ad-hoc/dev builds can't mint that entitlement, so writes retry without
+// the group (simulator keychains aren't segmented, which is what makes the
+// simulator end-to-end push test work).
+enum SharedThreadKeys {
+ struct Entry: Codable {
+ let key: Data // raw AES-GCM thread key
+ let title: String // decrypted chat title (never stored server-side)
+ let origin: String // owning instance, for notification → chat routing
+ let group: Bool // groups prefix the sender name in the banner
+ }
+
+ private static let service = "com.lchat.client.threadkeys"
+ // TeamID prefix + shared suffix; only valid with the keychain-sharing
+ // entitlement (paid signing). See the go-live notes in ios/README.md.
+ private static let accessGroup = "5ANZE82673.com.rushedsunlight.eleven.shared"
+
+ static func save(threadID: String, entry: Entry) {
+ guard let data = try? JSONEncoder().encode(entry) else { return }
+ var base: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: threadID,
+ kSecUseDataProtectionKeychain as String: true,
+ kSecAttrAccessGroup as String: accessGroup,
+ ]
+ var add = base
+ add[kSecValueData as String] = data
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
+ SecItemDelete(base as CFDictionary)
+ if SecItemAdd(add as CFDictionary, nil) == errSecSuccess { return }
+ // No sharing entitlement (dev build): keep it app-local so the rest of
+ // the flow still works; the NSE then falls back to the generic banner.
+ base.removeValue(forKey: kSecAttrAccessGroup as String)
+ add.removeValue(forKey: kSecAttrAccessGroup as String)
+ SecItemDelete(base as CFDictionary)
+ SecItemAdd(add as CFDictionary, nil)
+ }
+
+ static func lookup(threadID: String) -> Entry? {
+ for withGroup in [true, false] {
+ var query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: threadID,
+ kSecUseDataProtectionKeychain as String: true,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ if withGroup { query[kSecAttrAccessGroup as String] = accessGroup }
+ var item: CFTypeRef?
+ if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data,
+ let entry = try? JSONDecoder().decode(Entry.self, from: data) {
+ return entry
+ }
+ }
+ return nil
+ }
+}
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift
index 85e3b74..c41d3ae 100644
--- a/ios/LChat/ThreadStore.swift
+++ b/ios/LChat/ThreadStore.swift
@@ -68,6 +68,11 @@ final class ThreadStore: ObservableObject {
preview = previewText(kind: t.kind, payload: p)
}
let lastAt = t.last_at ?? 0
+ // Park the key + decrypted title for the notification extension: a
+ // push's ciphertext is decrypted on-device against this store.
+ SharedThreadKeys.save(threadID: t.id, entry: .init(
+ key: raw, title: threadTitle(t, key: key),
+ origin: instance.origin, group: t.kind == "group"))
rows.append(Thread(
id: t.id,
kind: t.kind,
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 411ba88..704ba25 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -57,7 +57,13 @@ struct ThreadListView: View {
ChatView(instance: store.instance, thread: t,
myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
.id(store.instance.id + "|" + t.id) // fresh webview per thread
- .onAppear { store.markRead(t) }
+ .onAppear {
+ store.markRead(t)
+ PushManager.activeThreadID = t.id // suppress banners for the open chat
+ }
+ .onDisappear {
+ if PushManager.activeThreadID == t.id { PushManager.activeThreadID = nil }
+ }
} else {
ContentUnavailableView("Chat not found", systemImage: "bubble.left")
}
diff --git a/ios/LChatNSE/Info.plist b/ios/LChatNSE/Info.plist
new file mode 100644
index 0000000..0c24d25
--- /dev/null
+++ b/ios/LChatNSE/Info.plist
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleDisplayName</key>
+ <string>Eleven Notifications</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>$(PRODUCT_NAME)</string>
+ <key>CFBundlePackageType</key>
+ <string>XPC!</string>
+ <key>CFBundleShortVersionString</key>
+ <string>1.0</string>
+ <key>CFBundleVersion</key>
+ <string>1</string>
+ <key>NSExtension</key>
+ <dict>
+ <key>NSExtensionPointIdentifier</key>
+ <string>com.apple.usernotifications.service</string>
+ <key>NSExtensionPrincipalClass</key>
+ <string>$(PRODUCT_MODULE_NAME).NotificationService</string>
+ </dict>
+</dict>
+</plist>
diff --git a/ios/LChatNSE/LChatNSE.entitlements b/ios/LChatNSE/LChatNSE.entitlements
new file mode 100644
index 0000000..f60a67e
--- /dev/null
+++ b/ios/LChatNSE/LChatNSE.entitlements
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <!-- Same keychain group as the app: this is where the thread keys live that
+ let the extension decrypt pushed ciphertext on-device. -->
+ <key>keychain-access-groups</key>
+ <array>
+ <string>$(AppIdentifierPrefix)com.rushedsunlight.eleven.shared</string>
+ </array>
+</dict>
+</plist>
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift
new file mode 100644
index 0000000..30a95db
--- /dev/null
+++ b/ios/LChatNSE/NotificationService.swift
@@ -0,0 +1,47 @@
+import UserNotifications
+
+// The E2EE half of push: the APNs payload carries only the ciphertext blob and
+// thread id (apns.go) — this extension decrypts it ON DEVICE with the thread key
+// the app parked in the shared keychain (SharedThreadKeys) and rewrites the
+// placeholder "New message" banner into the real sender + text. If anything is
+// missing (no key yet, undecryptable, dev build without the shared keychain
+// entitlement) the placeholder ships as-is — never an error, never plaintext
+// leaving the device.
+final class NotificationService: UNNotificationServiceExtension {
+ private var handler: ((UNNotificationContent) -> Void)?
+ private var bestEffort: UNMutableNotificationContent?
+
+ override func didReceive(_ request: UNNotificationRequest,
+ withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+ handler = contentHandler
+ let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent()
+ bestEffort = content
+
+ let info = request.content.userInfo
+ guard let thread = info["thread"] as? String,
+ let blob = info["blob"] as? String,
+ let entry = SharedThreadKeys.lookup(threadID: thread),
+ let key = try? GroupKey(rawKey: entry.key),
+ let payload = try? key.decryptJSON(MessagePayload.self, blob: blob)
+ else {
+ contentHandler(content)
+ return
+ }
+
+ let sender = payload.name ?? "Someone"
+ var text = payload.text ?? ""
+ if text.isEmpty {
+ let n = payload.attachments?.count ?? 0
+ text = n > 0 ? (n == 1 ? "📎 Attachment" : "📎 \(n) attachments") : "New message"
+ }
+ content.title = entry.title
+ // In a DM the title already names the sender; groups prefix them.
+ content.body = entry.group ? "\(sender): \(text)" : text
+ content.threadIdentifier = thread
+ contentHandler(content)
+ }
+
+ override func serviceExtensionTimeWillExpire() {
+ if let bestEffort { handler?(bestEffort) }
+ }
+}
diff --git a/ios/LChatTests/PushBlobDecryptTest.swift b/ios/LChatTests/PushBlobDecryptTest.swift
new file mode 100644
index 0000000..99b78f8
--- /dev/null
+++ b/ios/LChatTests/PushBlobDecryptTest.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+// Standalone gate for the notification extension's decrypt path: a push payload's
+// blob (produced by the web crypto — see the fixture generator note in the repo)
+// must open with GroupKey exactly as the NSE does it. Run via:
+//
+// cd ios && swiftc -parse-as-library LChat/Crypto.swift \
+// LChatTests/PushBlobDecryptTest.swift -o /tmp/pushvec \
+// && /tmp/pushvec LChatTests/push-blob-vector.json
+//
+// A non-zero exit means the NSE would fall back to the generic banner for every
+// push — the wire format drifted from crypto.js.
+@main
+struct PushBlobDecryptTest {
+ struct Vector: Decodable {
+ let keyB64: String
+ let blob: String
+ let expectName: String
+ let expectText: String
+ }
+ struct Payload: Decodable {
+ let name: String?
+ let text: String?
+ }
+
+ static func main() {
+ let args = CommandLine.arguments
+ let path = args.count > 1 ? args[1] : "LChatTests/push-blob-vector.json"
+ guard let data = FileManager.default.contents(atPath: path),
+ let v = try? JSONDecoder().decode(Vector.self, from: data) else {
+ fail("could not read/parse fixture at \(path)")
+ }
+ guard let raw = Data(base64urlEncoded: v.keyB64) else {
+ fail("fixture key is not valid base64url")
+ }
+ do {
+ let key = try GroupKey(rawKey: raw)
+ let payload = try key.decryptJSON(Payload.self, blob: v.blob)
+ guard payload.name == v.expectName, payload.text == v.expectText else {
+ fail("decrypted mismatch: \(String(describing: payload.name)) / \(String(describing: payload.text))")
+ }
+ } catch {
+ fail("decrypt failed: \(error)")
+ }
+ print("PushBlobDecryptTest OK")
+ }
+
+ static func fail(_ msg: String) -> Never {
+ FileHandle.standardError.write(Data(("PushBlobDecryptTest FAIL: " + msg + "\n").utf8))
+ exit(1)
+ }
+}
diff --git a/ios/LChatTests/push-blob-vector.json b/ios/LChatTests/push-blob-vector.json
new file mode 100644
index 0000000..3124be9
--- /dev/null
+++ b/ios/LChatTests/push-blob-vector.json
@@ -0,0 +1,6 @@
+{
+ "keyB64": "GIBWdnyFF-4HF1VfCY8xMokhfRKPl8r68jhv_qfpRKg",
+ "blob": "q7RS8SbZWtSFSPVBXt7lVxCEent6HBflwR3wR-aZhvAy-88YRhAJM9SgRmMJBsg6KTeFTGDq3iVfn8_5Vcn-R2DI5lPfJGSSmnG83YPWly0tSpRAOLaPeh3dUYF5PexmUW647JY_jYHGmw2TqA",
+ "expectName": "Paula",
+ "expectText": "Psst — this arrived encrypted 🎉"
+}
diff --git a/ios/LChatUITests/JoinAndChatTests.swift b/ios/LChatUITests/JoinAndChatTests.swift
index 29a10ed..ed0c298 100644
--- a/ios/LChatUITests/JoinAndChatTests.swift
+++ b/ios/LChatUITests/JoinAndChatTests.swift
@@ -67,6 +67,20 @@ final class JoinAndChatTests: XCTestCase {
shoot(app, "05-reply-received")
}
+ // Approve the notification-permission alert (springboard-owned) so push
+ // verification can deliver banners. Run once after a join.
+ func testGrantNotifications() throws {
+ guard ProcessInfo.processInfo.environment["GRANT_PUSH"] == "1" else {
+ throw XCTSkip("Set TEST_RUNNER_GRANT_PUSH=1 to run the permission grant.")
+ }
+ let app = XCUIApplication()
+ app.launch()
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
+ let allow = springboard.buttons["Allow"].firstMatch
+ if allow.waitForExistence(timeout: 10) { allow.tap() } // already granted otherwise
+ sleep(1)
+ }
+
// Screens-only pass over an already-joined install: open the first chat,
// raise the keyboard, screenshot each state. For eyeballing layout work
// (floating bars, insets) without burning an invite.
diff --git a/ios/README.md b/ios/README.md
index c438eff..c5852cd 100644
--- a/ios/README.md
+++ b/ios/README.md
@@ -149,3 +149,32 @@ cd ios && swiftc -parse-as-library LChat/Crypto.swift \
LChatTests/ECIESVectorTest.swift -o /tmp/eciesvec \
&& /tmp/eciesvec LChatTests/thread-key-vector.json
```
+
+## Push notifications (APNs) — built, gated on paid signing
+
+The pipeline is E2EE-preserving end to end: the server pushes only the
+ciphertext blob + thread id (`apns.go`); the app parks each thread's key in the
+shared keychain (`SharedThreadKeys`); the notification extension (`LChatNSE`)
+decrypts on-device and rewrites the banner. Registration + routing live in
+`PushManager.swift`.
+
+Verified: server handler tests (`TestAPNsSubscribe`), the cross-implementation
+decrypt gate (`LChatTests/PushBlobDecryptTest.swift` — run like the ECIES
+vector), banner delivery + permission flow in the simulator. **The simulator
+does NOT run notification service extensions for `simctl push`** — banners show
+the placeholder there; the decrypted rewrite can only be seen on a real device
+with real APNs.
+
+To go live (needs a paid Apple Developer membership):
+
+1. developer.apple.com → Keys → new key with the APNs service → download the
+ `.p8`, note the Key ID and Team ID.
+2. Deploy each instance with the extra env (base64 the key to survive env
+ quoting): `LCHAT_APNS_KEY` (p8 PEM or its base64), `LCHAT_APNS_KEY_ID`,
+ `LCHAT_APNS_TEAM_ID`, `LCHAT_APNS_TOPIC` (the app bundle id).
+3. In the app project: add the `aps-environment` entitlement to LChat, sign
+ LChat + LChatNSE with the paid team, and give BOTH targets the same
+ keychain access group (update the constant in `SharedThreadKeys.swift` to
+ `<TeamID>.<bundle id>.shared`).
+4. Dev builds register with `sandbox: true` (`#if DEBUG`) and are delivered via
+ Apple's sandbox gateway; TestFlight/App Store builds use production.
diff --git a/ios/project.yml b/ios/project.yml
index 481d755..8e1e2c4 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -10,15 +10,21 @@ settings:
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "1"
TARGETED_DEVICE_FAMILY: "1,2"
+ # One knob for the app id; the extension derives from it so a per-machine
+ # override (e.g. LCHAT_APP_BUNDLE_ID=com.example.mine on the xcodebuild
+ # line for device signing) can never collide the two bundle ids.
+ LCHAT_APP_BUNDLE_ID: com.lchat.client
targets:
LChat:
type: application
platform: iOS
sources:
- path: LChat
+ dependencies:
+ - target: LChatNSE
settings:
base:
- PRODUCT_BUNDLE_IDENTIFIER: com.lchat.client
+ PRODUCT_BUNDLE_IDENTIFIER: $(LCHAT_APP_BUNDLE_ID)
CODE_SIGN_STYLE: Automatic
# Also build a real Mac app from the same code via Mac Catalyst.
SUPPORTS_MACCATALYST: YES
@@ -43,6 +49,27 @@ targets:
# test against a `go run .` instance during development.
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
+ # Notification service extension: decrypts pushed ciphertext on-device (the
+ # E2EE half of APNs). Shares the crypto/keychain sources with the app.
+ LChatNSE:
+ type: app-extension
+ platform: iOS
+ sources:
+ - path: LChatNSE
+ - path: LChat/Crypto.swift
+ - path: LChat/Models.swift
+ - path: LChat/SharedThreadKeys.swift
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: $(LCHAT_APP_BUNDLE_ID).nse
+ CODE_SIGN_ENTITLEMENTS: LChatNSE/LChatNSE.entitlements
+ info:
+ path: LChatNSE/Info.plist
+ properties:
+ CFBundleDisplayName: Eleven Notifications
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.usernotifications.service
+ NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).NotificationService
# End-to-end UI tests (Simulator only). They need a live server and an invite
# link, passed as TEST_RUNNER_INVITE_LINK on the xcodebuild invocation; without
# it the test skips itself. See LChatUITests/JoinAndChatTests.swift.
diff --git a/main.go b/main.go
index 1c6d13f..9a5f5d7 100644
--- a/main.go
+++ b/main.go
@@ -45,6 +45,7 @@ type Server struct {
version string // frontend build hash; clients reload when it changes (see frontendVersion)
vapidPub string
vapidPriv string
+ apns *apnsClient // nil unless LCHAT_APNS_* env is configured
// inflight de-duplicates concurrent unfurls of the same URL.
inflight sync.Map
// active tracks the last time each device token reported being open + focused
@@ -97,6 +98,7 @@ func main() {
} else {
s.vapidPub, s.vapidPriv = pub, priv
}
+ s.apns = newAPNsClient()
log.Printf("lchat listening on %s (db: %s)", *addr, *dbPath)
if err := http.ListenAndServe(*addr, s.handler()); err != nil {
@@ -131,6 +133,8 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/bot-config", s.getBotConfig)
mux.HandleFunc("GET /api/me", s.getMe)
mux.HandleFunc("POST /api/device/signout", s.signout)
+ mux.HandleFunc("POST /api/apns/subscribe", s.apnsSubscribe)
+ mux.HandleFunc("POST /api/apns/unsubscribe", s.apnsUnsubscribe)
// Instance key + profile photos: a shared key for all-members content (avatars).
mux.HandleFunc("GET /api/instance-key", s.getInstanceKey)
mux.HandleFunc("POST /api/instance-key", s.postInstanceKey)
@@ -526,6 +530,7 @@ func (s *Server) signout(w http.ResponseWriter, r *http.Request) {
}
_ = s.db.deleteDeviceToken(body.Token)
_ = s.db.deleteSubscriptionsForToken(body.Token)
+ _ = s.db.deleteAPNsTokensForToken(body.Token)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -1558,6 +1563,7 @@ func (s *Server) revokeInvite(w http.ResponseWriter, r *http.Request) {
}
// A revoked participant should also stop receiving push notifications.
_ = s.db.deleteSubscriptionsForToken(token)
+ _ = s.db.deleteAPNsTokensForToken(token)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -1756,3 +1762,50 @@ func printAdminLink(addr, token string) {
}
log.Printf("\n\n Admin link (open once, keep it secret):\n http://%s/admin#token=%s\n\n", host, token)
}
+
+// ---- APNs (native iOS app) -----------------------------------------------
+
+// apnsSubscribe registers an iPhone's APNs device token against the caller's
+// session token, so the same suppression rules as Web Push apply. sandbox marks
+// tokens from development builds (delivered via Apple's sandbox gateway).
+func (s *Server) apnsSubscribe(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ APNsToken string `json:"apns_token"`
+ Sandbox bool `json:"sandbox"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.tokenOK(body.Token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ if body.APNsToken == "" || len(body.APNsToken) > 200 {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if err := s.db.addAPNsToken(APNsToken{APNsToken: body.APNsToken, Token: body.Token, Sandbox: body.Sandbox}); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "enabled": s.apns != nil})
+}
+
+func (s *Server) apnsUnsubscribe(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ APNsToken string `json:"apns_token"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.tokenOK(body.Token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ _ = s.db.deleteAPNsToken(body.APNsToken)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
diff --git a/push.go b/push.go
index 2bfa357..55043d4 100644
--- a/push.go
+++ b/push.go
@@ -107,6 +107,9 @@ func (s *Server) pushNewMessage(blob, threadID, senderToken string) {
// skipActive=true: don't notify people currently reading on another device.
// recipients=set: only this thread's members, never non-members.
s.sendToRecipients(payload, "msg", senderToken, true, set)
+ // Same message, same rules, over APNs to the native iOS apps (no-op unless
+ // LCHAT_APNS_* is configured).
+ s.apnsFanOut(threadID, blob, senderToken, true, set)
}
// sendTestPush delivers a generic "it works" notification to every subscriber,