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>
21 files changed,
+830
−3
apns.go+265 −0changelog/2026-07-02-ios-push-notifications.md+4 −0db.go+9 −0deploy-instance.sh+7 −1handlers_test.go+58 −0ios/LChat/LChat.entitlements+10 −0ios/LChat/LChatApp.swift+22 −0ios/LChat/PushManager.swift+98 −0ios/LChat/SharedThreadKeys.swift+70 −0ios/LChat/ThreadStore.swift+5 −0ios/LChat/Views/ThreadListView.swift+7 −1ios/LChatNSE/Info.plist+31 −0ios/LChatNSE/LChatNSE.entitlements+12 −0ios/LChatNSE/NotificationService.swift+47 −0ios/LChatTests/PushBlobDecryptTest.swift+52 −0ios/LChatTests/push-blob-vector.json+6 −0ios/LChatUITests/JoinAndChatTests.swift+14 −0ios/README.md+29 −0ios/project.yml+28 −1main.go+53 −0push.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, |