eleven / messenger Public

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>
Paul Campbell 85693730e4c41010547695d65a26636864c2bf66 parent a4f0c39
9 files changed, +203 −6
  • announce.go +6 −1
  • announce_test.go +43 −0
  • apns.go +56 −3
  • internal/apns/apns.go +32 −1
  • internal/apns/apns_test.go +11 −0
  • ios/LChat/LChatApp.swift +14 −0
  • ios/LChat/PushManager.swift +10 −0
  • push/main.go +14 −1
  • push/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)
+ }
+}