apns+ios: the "What's new" banner reaches admins' iPhones (#468)
Part 3 of retiring the Announcer: the changelog announcement (announce.go)
now also rides APNs. It travels as a PLAIN alert push — title/body in the aps
envelope, no mutable-content — because the words are branding-class plaintext
(baked into the binary, announced publicly), never user content. That choice
buys two things: the NSE never runs (nothing to decrypt), and the banner
displays correctly on app builds that predate this PR; only tap-routing needs
the new build.
- internal/apns: BuildAlertPayload + SendAlert (Send/SendAlert share one post
path); envelope pinned by test so messenger and relay can't drift.
- apns.go: sender interface gains sendAlert (direct + relay); the fan-out
selection loop is extracted (apnsSendSelected) and apnsAnnounce reuses it,
filtered to the admin recipient set; tighter body cap for the OS limit.
- announce.go: announceChangelog fans out over APNs beside Web Push.
- push/ (relay): accepts {changelog, title, body} and forwards via the same
shared envelope. An OLD relay ignores the fields and forwards an empty-blob
push — the admin sees the placeholder "New message" until the relay is
redeployed: degraded, never broken.
- ios: tapping the banner opens the current space's Admin app-thread (the
payload can't name the thread — its name rides encrypted — so it resolves
locally; the banner title names the space for multi-space admins).
No changelog entry: the server/relay side is covered by part 2's entry, and
the native tap-routing waits for the next release per convention.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+203
−6
announce.go+6 −1announce_test.go+43 −0apns.go+56 −3internal/apns/apns.go+32 −1internal/apns/apns_test.go+11 −0ios/LChat/LChatApp.swift+14 −0ios/LChat/PushManager.swift+10 −0push/main.go+14 −1push/push_test.go+17 −0
diff --git a/announce.go b/announce.go| index 954e1d1..938cac2 100644 |
| --- a/announce.go |
| +++ b/announce.go |
| @@ -101,6 +101,7 @@ func (s *Server) announceChangelog() (fresh, sent int) { |
| recipients[id] = true |
| } |
| + title := "What's new in " + s.db.getGroupName() |
| body := strings.Join(texts, "\n\n") |
| // Web Push payloads cap around 4KB; the OS truncates banners far sooner. |
| if r := []rune(body); len(r) > 1500 { |
| @@ -108,13 +109,17 @@ func (s *Server) announceChangelog() (fresh, sent int) { |
| } |
| payload, _ := json.Marshal(map[string]any{ |
| "changelog": true, |
| - "title": "What's new in " + s.db.getGroupName(), |
| + "title": title, |
| "body": body, |
| }) |
| // skipActive=false: a deploy just restarted the server, so presence is empty |
| // anyway — and this banner is the one signal an admin gets while the Admin |
| // app is closed. exceptToken="": there is no sender to exclude. |
| sent = s.sendToRecipients(payload, "changelog", "", false, recipients) |
| + // The same banner over APNs to the admins' iPhones (apnsAnnounce truncates |
| + // tighter for the OS cap; no-op when APNs isn't configured). Plaintext by |
| + // the same branding-class argument as the Web Push payload above. |
| + sent += s.apnsAnnounce(title, body, recipients) |
| log.Printf("changelog: announced %d entr(y/ies) to %d admin subscription(s)", len(texts), sent) |
| return len(texts), sent |
| } |
diff --git a/announce_test.go b/announce_test.go| index 6028304..bbe7391 100644 |
| --- a/announce_test.go |
| +++ b/announce_test.go |
| @@ -2,6 +2,7 @@ package main |
| import ( |
| "encoding/json" |
| + "sync" |
| "testing" |
| ) |
| @@ -94,3 +95,45 @@ func TestAdminUserIDs(t *testing.T) { |
| } |
| _ = b // stays a member — must not appear |
| } |
| + |
| +// stubAPNs records sendAlert calls so apnsAnnounce's admin filtering is |
| +// observable without any network. |
| +type stubAPNs struct { |
| + mu sync.Mutex |
| + alerts []string // "apnsToken|title|body" |
| +} |
| + |
| +func (s *stubAPNs) send(string, bool, string, string, int64, int64) error { return nil } |
| +func (s *stubAPNs) sendAlert(deviceToken string, _ bool, title, body string) error { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + s.alerts = append(s.alerts, deviceToken+"|"+title+"|"+body) |
| + return nil |
| +} |
| + |
| +func TestAPNsAnnounceReachesOnlyAdmins(t *testing.T) { |
| + _, s := newTestServer(t) |
| + stub := &stubAPNs{} |
| + s.apns = stub |
| + |
| + adminTok := mintDeviceToken(t, s.db, "Ada") |
| + memberTok := mintDeviceToken(t, s.db, "Bob") |
| + adminUID, _ := s.db.deviceTokenUser(adminTok) |
| + if err := s.db.setUserRole(adminUID, "admin"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.addAPNsToken(APNsToken{APNsToken: "apns-ada", Token: adminTok}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.addAPNsToken(APNsToken{APNsToken: "apns-bob", Token: memberTok}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + sent := s.apnsAnnounce("What's new", "Notes.", map[string]bool{adminUID: true}) |
| + if sent != 1 { |
| + t.Fatalf("sent=%d, want 1 (admin only)", sent) |
| + } |
| + if len(stub.alerts) != 1 || stub.alerts[0] != "apns-ada|What's new|Notes." { |
| + t.Fatalf("unexpected alerts: %v", stub.alerts) |
| + } |
| +} |
diff --git a/apns.go b/apns.go| index 5f6200a..4142a52 100644 |
| --- a/apns.go |
| +++ b/apns.go |
| @@ -101,6 +101,10 @@ func (db *DB) deleteAPNsTokensForToken(token string) error { |
| // push relay. nil means APNs is disabled (Web Push only), exactly as before. |
| type apnsSender interface { |
| send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error |
| + // sendAlert carries a PLAINTEXT banner — only ever the changelog |
| + // announcement (announce.go), which is branding-class text, not user |
| + // content. Same ErrGone/prune contract as send. |
| + sendAlert(deviceToken string, sandbox bool, title, body string) error |
| } |
| // directSender is today's transport: the .p8 lives in this instance's env and |
| @@ -111,6 +115,10 @@ func (d directSender) send(deviceToken string, sandbox bool, threadID, blob stri |
| return d.c.Send(deviceToken, sandbox, threadID, blob, id, at) |
| } |
| +func (d directSender) sendAlert(deviceToken string, sandbox bool, title, body string) error { |
| + return d.c.SendAlert(deviceToken, sandbox, title, body) |
| +} |
| + |
| // relaySender forwards to push.elevenmessenger.com. It posts the same |
| // {device_token, sandbox, thread, blob} the relay needs — all opaque, no key — |
| // authenticated with this instance's per-instance bearer token. The relay |
| @@ -127,10 +135,24 @@ type relaySender struct { |
| func (r relaySender) send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error { |
| // id/at ride along for the NSE's local-store ingest (see apns.BuildPayload); |
| // an older relay just ignores the extra fields and the feature stays off. |
| - body, _ := json.Marshal(map[string]any{ |
| + return r.post(map[string]any{ |
| "device_token": deviceToken, "sandbox": sandbox, "thread": threadID, "blob": blob, |
| "id": id, "at": at, |
| }) |
| +} |
| + |
| +func (r relaySender) sendAlert(deviceToken string, sandbox bool, title, body string) error { |
| + // An older relay ignores title/body and forwards an empty-blob push — the |
| + // NSE can't decrypt it, so the admin sees the generic "New message" |
| + // placeholder until the relay is redeployed. Degraded, never broken. |
| + return r.post(map[string]any{ |
| + "device_token": deviceToken, "sandbox": sandbox, |
| + "changelog": true, "title": title, "body": body, |
| + }) |
| +} |
| + |
| +func (r relaySender) post(fields map[string]any) error { |
| + body, _ := json.Marshal(fields) |
| req, err := http.NewRequest("POST", r.url, bytes.NewReader(body)) |
| if err != nil { |
| return err |
| @@ -204,6 +226,37 @@ func (s *Server) apnsFanOut(threadID, blob string, id, at int64, exceptToken str |
| log.Println("apns: list tokens:", err) |
| return 0 |
| } |
| + return s.apnsSendSelected("msg", tokens, exceptToken, skipActive, recipients, func(t APNsToken) error { |
| + return s.apns.send(t.APNsToken, t.Sandbox, threadID, blob, id, at) |
| + }) |
| +} |
| + |
| +// apnsAnnounce carries the plaintext changelog banner (announce.go) to the |
| +// admins' iPhones — the APNs twin of the Web Push side of announceChangelog. |
| +// No sender to exclude and no active-skip, for the same reasons as there. The |
| +// alert payload has a hard OS cap, so the body is truncated tighter than Web |
| +// Push's. |
| +func (s *Server) apnsAnnounce(title, body string, recipients map[string]bool) int { |
| + if s.apns == nil { |
| + return 0 |
| + } |
| + tokens, err := s.db.listAPNsTokens() |
| + if err != nil { |
| + log.Println("apns: list tokens:", err) |
| + return 0 |
| + } |
| + if r := []rune(body); len(r) > 800 { |
| + body = string(r[:800]) + "…" |
| + } |
| + return s.apnsSendSelected("changelog", tokens, "", false, recipients, func(t APNsToken) error { |
| + return s.apns.sendAlert(t.APNsToken, t.Sandbox, title, body) |
| + }) |
| +} |
| + |
| +// apnsSendSelected applies the selectRecipients rules to APNs tokens (keep the |
| +// filter in lockstep with push.go's selectRecipients) and sends concurrently, |
| +// pruning tokens Apple reports gone. |
| +func (s *Server) apnsSendSelected(kind string, tokens []APNsToken, exceptToken string, skipActive bool, recipients map[string]bool, sendOne func(APNsToken) error) int { |
| senderUser := "" |
| exceptHash := "" |
| if exceptToken != "" { |
| @@ -235,7 +288,7 @@ func (s *Server) apnsFanOut(threadID, blob string, id, at int64, exceptToken str |
| go func(t APNsToken) { |
| defer wg.Done() |
| defer func() { <-sem }() |
| - err := s.apns.send(t.APNsToken, t.Sandbox, threadID, blob, id, at) |
| + err := sendOne(t) |
| switch { |
| case err == nil: |
| mu.Lock() |
| @@ -251,6 +304,6 @@ func (s *Server) apnsFanOut(threadID, blob string, id, at int64, exceptToken str |
| wg.Wait() |
| // Mirror push.go's summary line — silence here is indistinguishable from |
| // "no registered iPhones", which makes push failures undebuggable. |
| - log.Printf("apns[msg]: %d token(s), %d after exclusions, %d sent", len(tokens), selected, sent) |
| + log.Printf("apns[%s]: %d token(s), %d after exclusions, %d sent", kind, len(tokens), selected, sent) |
| return sent |
| } |
diff --git a/internal/apns/apns.go b/internal/apns/apns.go| index 86c6686..217dd1a 100644 |
| --- a/internal/apns/apns.go |
| +++ b/internal/apns/apns.go |
| @@ -173,9 +173,40 @@ func BuildPayload(threadID, blob string, id, at int64) []byte { |
| return payload |
| } |
| +// BuildAlertPayload builds the aps envelope for a PLAINTEXT banner — the |
| +// changelog announcement to admins (announce.go in the messenger). Unlike |
| +// BuildPayload there is no mutable-content and no ciphertext: the words are |
| +// branding-class plaintext (the changelog ships in the binary and is announced |
| +// publicly, never user content), so the system shows title/body as-is and the |
| +// NSE never runs — which is also why the banner works on app builds that |
| +// predate it. The `changelog` marker lets a tap route to the Admin app. |
| +// Exported so the messenger and the relay can't drift (same rule as |
| +// BuildPayload). |
| +func BuildAlertPayload(title, body string) []byte { |
| + m := map[string]any{ |
| + "aps": map[string]any{ |
| + "alert": map[string]string{"title": title, "body": body}, |
| + "sound": "default", |
| + }, |
| + "changelog": true, |
| + } |
| + payload, _ := json.Marshal(m) |
| + return payload |
| +} |
| + |
| // Send posts one alert to one device, returning ErrGone when Apple says the |
| // token is dead so the caller can prune it. |
| func (c *Client) Send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error { |
| + return c.post(deviceToken, sandbox, BuildPayload(threadID, blob, id, at)) |
| +} |
| + |
| +// SendAlert posts a plaintext banner (BuildAlertPayload) to one device. Same |
| +// ErrGone/prune contract as Send. |
| +func (c *Client) SendAlert(deviceToken string, sandbox bool, title, body string) error { |
| + return c.post(deviceToken, sandbox, BuildAlertPayload(title, body)) |
| +} |
| + |
| +func (c *Client) post(deviceToken string, sandbox bool, payload []byte) error { |
| if !ValidDeviceToken(deviceToken) { |
| return errors.New("apns: invalid device token") // never ErrGone — not a prune signal |
| } |
| @@ -183,7 +214,7 @@ func (c *Client) Send(deviceToken string, sandbox bool, threadID, blob string, i |
| if sandbox { |
| host = c.sandboxHost |
| } |
| - req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(BuildPayload(threadID, blob, id, at))) |
| + req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(payload)) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/apns/apns_test.go b/internal/apns/apns_test.go| index ed9e730..de8d011 100644 |
| --- a/internal/apns/apns_test.go |
| +++ b/internal/apns/apns_test.go |
| @@ -124,3 +124,14 @@ func TestSendRejectsBadToken(t *testing.T) { |
| t.Error("bad token reached the upstream server — validation is too late") |
| } |
| } |
| + |
| +func TestBuildAlertPayloadStable(t *testing.T) { |
| + // The plaintext changelog banner (announce.go): no mutable-content (the NSE |
| + // never runs — the words are already readable), a `changelog` marker for tap |
| + // routing. Pinned like BuildPayload so the messenger and relay can't drift. |
| + got := string(BuildAlertPayload("What's new in Test Space", "Updates take seconds.")) |
| + want := `{"aps":{"alert":{"body":"Updates take seconds.","title":"What's new in Test Space"},"sound":"default"},"changelog":true}` |
| + if got != want { |
| + t.Errorf("alert envelope drift:\n got: %s\nwant: %s", got, want) |
| + } |
| +} |
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 3d725f4..92aaffd 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -296,6 +296,7 @@ struct RootView: View { |
| // A cold-launch notification tap is already waiting here (delivered |
| // before this view could subscribe) — route it now. |
| routeNotificationTap() |
| + routeChangelogTap() |
| } |
| // Sign-in restore populates instances while the first-run screen is |
| // up — carry the user straight into their spaces rather than leaving |
| @@ -308,6 +309,7 @@ struct RootView: View { |
| // A tap that arrives while we're already up: the published id changes and |
| // this fires. (onAppear covers the cold-launch case where it was set first.) |
| .onChange(of: push.pendingThreadID) { _, _ in routeNotificationTap() } |
| + .onChange(of: push.pendingChangelogTap) { _, _ in routeChangelogTap() } |
| // Coming back to the app re-fetches the active server's chats, so a chat |
| // started with us while we were away (thread creation sends no push) is |
| // there when we look. The `.task(id:)` in ThreadListView only fires on first |
| @@ -352,6 +354,18 @@ struct RootView: View { |
| if path.last != threadID { path.append(threadID) } |
| } |
| + // A tapped "What's new" banner (the release-notes push, announce.go). The |
| + // payload carries no thread id — the Admin thread's name rides encrypted, so |
| + // the server can't address it — so resolve locally: open the CURRENT space's |
| + // Admin app-thread if it has one, else just land in the app. The banner |
| + // title names the space, so a multi-space admin can see which one updated. |
| + private func routeChangelogTap() { |
| + guard push.pendingChangelogTap else { return } |
| + push.pendingChangelogTap = false |
| + guard let admin = nav.store?.threads.first(where: { $0.appId == "admin" }) else { return } |
| + if path.last != admin.id { path.append(admin.id) } |
| + } |
| + |
| private func select(_ inst: SavedInstance) { |
| path = [] // pushed chats belong to the previous server |
| nav.selectInstance(inst) |
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift| index 0104a12..b666d16 100644 |
| --- a/ios/LChat/PushManager.swift |
| +++ b/ios/LChat/PushManager.swift |
| @@ -33,6 +33,13 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| // on that message even if the parked feed had been scrolled up into history. |
| static var forceScrollThreadID: String? |
| + // A tapped changelog banner ("What's new in …", announce.go) awaiting routing. |
| + // The payload carries no thread id — the server can't know the Admin thread's |
| + // id (its name rides encrypted) — so RootView resolves it locally: the current |
| + // space's Admin app-thread if there is one, else just the app. Published for |
| + // the same cold-launch reason as pendingThreadID. |
| + @Published var pendingChangelogTap = false |
| + |
| func application(_ application: UIApplication, |
| didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { |
| UNUserNotificationCenter.current().delegate = self |
| @@ -137,6 +144,9 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| // main thread, so touching @Published / this static here is safe.) |
| Self.forceScrollThreadID = thread |
| pendingThreadID = thread |
| + } else if response.notification.request.content.userInfo["changelog"] != nil { |
| + // "What's new" (the release-notes banner): route to the Admin app. |
| + pendingChangelogTap = true |
| } |
| completionHandler() |
| } |
diff --git a/push/main.go b/push/main.go| index cc7a79d..0382ca2 100644 |
| --- a/push/main.go |
| +++ b/push/main.go |
| @@ -170,6 +170,14 @@ func (s *Server) pushHandler(w http.ResponseWriter, r *http.Request) { |
| // delivery timing. Optional — older instances omit them. |
| ID int64 `json:"id"` |
| At int64 `json:"at"` |
| + // A changelog announcement (announce.go in the messenger): a PLAINTEXT |
| + // banner instead of a ciphertext blob. The one non-blind payload the |
| + // relay carries, and deliberately so — release notes are branding-class |
| + // text baked into every binary, never user content. Mutually exclusive |
| + // with thread/blob. |
| + Changelog bool `json:"changelog"` |
| + Title string `json:"title"` |
| + Body string `json:"body"` |
| } |
| r.Body = http.MaxBytesReader(w, r.Body, maxPushBytes) |
| if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| @@ -187,7 +195,12 @@ func (s *Server) pushHandler(w http.ResponseWriter, r *http.Request) { |
| } |
| start := time.Now() |
| - err := s.apns.Send(body.DeviceToken, body.Sandbox, body.Thread, body.Blob, body.ID, body.At) |
| + var err error |
| + if body.Changelog { |
| + err = s.apns.SendAlert(body.DeviceToken, body.Sandbox, body.Title, body.Body) |
| + } else { |
| + err = s.apns.Send(body.DeviceToken, body.Sandbox, body.Thread, body.Blob, body.ID, body.At) |
| + } |
| latency := time.Since(start).Milliseconds() |
| switch { |
| case err == nil: |
diff --git a/push/push_test.go b/push/push_test.go| index 12977ce..08c51a9 100644 |
| --- a/push/push_test.go |
| +++ b/push/push_test.go |
| @@ -187,3 +187,20 @@ func TestHealthz(t *testing.T) { |
| t.Errorf("healthz: want 200, got %d", resp.StatusCode) |
| } |
| } |
| + |
| +// A changelog announcement: plaintext title/body instead of a ciphertext blob |
| +// (the one non-blind payload — branding-class release notes). Same 200/410 |
| +// contract as message pushes. |
| +func TestPushChangelogAlert(t *testing.T) { |
| + base := newTestRelay(t).URL |
| + _, tok := mint(t, base, "inst") |
| + body := `{"device_token":"` + liveDevice + `","changelog":true,"title":"What's new","body":"Notes."}` |
| + if resp := do(t, "POST", base+"/api/push", tok, body); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("changelog push: status %d", resp.StatusCode) |
| + } |
| + // Dead tokens prune through the changelog path too. |
| + body = `{"device_token":"` + deadDevice + `","changelog":true,"title":"What's new","body":"Notes."}` |
| + if resp := do(t, "POST", base+"/api/push", tok, body); resp.StatusCode != http.StatusGone { |
| + t.Fatalf("changelog push to dead device: status %d, want 410", resp.StatusCode) |
| + } |
| +} |