push: return the device id and badge the alert !450

merged merged by cmc on 2026-09-20 22:46 UTC · krz/gitbay:push-device-id-badge into main

10 files changed, +95 −13

Layout: unified · split

CHANGELOG.org +6
@@ -14,6 +14,12 @@ A push alert names the account it is for (#89).
1414 =instance=, the instance's =site_url=, and =user=, the recipient's
1515 username — together the account's identity.
1616- =DuePush= joins =users= for the recipient; no schema change.
17- =notifications device add= now returns the row id, so a client that
18 wants to deregister does not have to list devices and match its own
19 token against the truncated display value.
20- The alert carries =aps.badge=, the recipient's unread inbox count.
21 =DuePush= counts it at send time, a subquery against =inbox=, so a
22 cleared inbox is reflected even though the row was queued earlier.
1723
1824* v1.32.0 — 2026-09-20
1925
docs/specs/2026-09-20-ios-push-notifications-design.md +5 −2
@@ -151,7 +151,7 @@ The capability lands in the registry; the surfaces render it.
151151
152152| Command | Notes |
153153|---|---|
154| `notifications device add` | `--label <name>`, token on stdin. `ReadsStdin: true`. |
154| `notifications device add` | `--label <name>`, token on stdin. `ReadsStdin: true`. Returns the row id, for deregistration. |
155155| `notifications device list` | `ReadOnly`. Token shown truncated, never in full. |
156156| `notifications device remove <id>` | Own devices only. |
157157| `notifications settings push on\|off` | Joins `settings mail` and `settings watch`. |
@@ -205,6 +205,7 @@ The payload:
205205 "aps": {
206206 "alert": {"title": "krz/gitbay", "body": "cmc opened issue #12"},
207207 "sound": "default",
208 "badge": 3,
208209 "thread-id": "krz/gitbay"
209210 },
210211 "path": "krz/gitbay/issues/12",
@@ -217,7 +218,9 @@ The payload:
217218string the inbox row carries, so the two surfaces cannot disagree.
218219`thread-id` groups a repository's notices in Notification Center.
219220`path` is the inbox row's `path` field, which the app already knows how
220to turn into a link.
221to turn into a link. `badge` is the recipient's unread inbox count,
222counted by `DuePush` at send time rather than at enqueue, so a cleared
223inbox is reflected.
221224
222225`instance` is this instance's `site_url` and `user` the recipient's
223226username. A device token is one install, and an install registers
e2e/push_test.go +4
@@ -133,6 +133,10 @@ environment = "production"
133133 if inst, _ := got[0]["instance"].(string); !strings.HasPrefix(inst, "https://") {
134134 t.Fatalf("instance = %v, want this instance's site_url", inst)
135135 }
136 // Bob's one unread notice, for the app icon badge.
137 if aps["badge"] != float64(1) {
138 t.Fatalf("badge = %v", aps["badge"])
139 }
136140
137141 // Apple retires the token. The next push reaps the device.
138142 mu.Lock()
internal/control/notifications.go +4 −3
@@ -274,11 +274,12 @@ func runNotificationsDeviceAdd(c *Ctx, args []string) int {
274274 if len(token) > maxDeviceTokenBytes {
275275 return c.fail(protocol.ExitUsage, "device token is too long")
276276 }
277 if _, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label")); err != nil {
277 id, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label"))
278 if err != nil {
278279 return c.fail(protocol.ExitFailure, "%v", err)
279280 }
280 return c.emit(map[string]string{"status": "registered"}, func(w io.Writer) {
281 fmt.Fprintln(w, "device registered")
281 return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) {
282 fmt.Fprintf(w, "device %d registered\n", id)
282283 })
283284}
284285
internal/control/notifications_test.go +22
@@ -2,6 +2,7 @@ package control
22
33import (
44 "bytes"
5 "fmt"
56 "strings"
67 "testing"
78
@@ -221,6 +222,27 @@ func TestNotificationsDeviceListMasksAShortToken(t *testing.T) {
221222 }
222223}
223224
225// device add returns the row id. Without it a client that wants to
226// deregister has to list devices and match its own token against the
227// truncated display value, which is identity by rendered string.
228func TestNotificationsDeviceAddReturnsTheID(t *testing.T) {
229 c := notifTestCtx(t, "alice")
230 c.Stdin = strings.NewReader("DEVTOKEN\n")
231 var out bytes.Buffer
232 c.Stdout, c.JSON = &out, true
233 if code := runNotificationsDeviceAdd(c, nil); code != 0 {
234 t.Fatalf("exit %d", code)
235 }
236 devices, _ := c.Store.PushDevices(c.User.ID)
237 if len(devices) != 1 {
238 t.Fatalf("want one device, got %d", len(devices))
239 }
240 want := fmt.Sprintf(`"id":%d`, devices[0].ID)
241 if !strings.Contains(out.String(), want) {
242 t.Fatalf("output %s does not carry %s", out.String(), want)
243 }
244}
245
224246func TestNotificationsSettingsShowsPush(t *testing.T) {
225247 c := notifTestCtx(t, "alice")
226248 var out bytes.Buffer
internal/push/apns.go +2 −1
@@ -111,7 +111,7 @@ func loopbackHost(hostport string) bool {
111111
112112// Send delivers one alert. The returned duration is the server's
113113// Retry-After when it gave one, zero otherwise.
114func (c *Client) Send(ctx context.Context, token, user, title, body, path string) (result, time.Duration, error) {
114func (c *Client) Send(ctx context.Context, token, user string, badge int, title, body, path string) (result, time.Duration, error) {
115115 if len(body) > maxBodyBytes {
116116 // A raw byte cut can land mid-rune on multi-byte UTF-8 (emoji,
117117 // accents, non-Latin usernames). ToValidUTF8 drops the
@@ -123,6 +123,7 @@ func (c *Client) Send(ctx context.Context, token, user, title, body, path string
123123 "aps": map[string]any{
124124 "alert": map[string]string{"title": title, "body": body},
125125 "sound": "default",
126 "badge": badge,
126127 "thread-id": title,
127128 },
128129 "path": path,
internal/push/apns_test.go +8 −4
@@ -45,7 +45,7 @@ func TestSendShapesTheRequest(t *testing.T) {
4545 json.Unmarshal(raw, &payload)
4646 w.WriteHeader(200)
4747 })
48 res, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc", "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12")
48 res, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc", 3, "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12")
4949 if err != nil || res != resultSent {
5050 t.Fatalf("res = %v, err = %v", res, err)
5151 }
@@ -103,7 +103,7 @@ func TestSendMapsResponses(t *testing.T) {
103103 w.WriteHeader(tc.status)
104104 io.WriteString(w, tc.body)
105105 })
106 res, after, err := c.Send(context.Background(), "T", "u", "t", "b", "p")
106 res, after, err := c.Send(context.Background(), "T", "u", 0, "t", "b", "p")
107107 // Only a delivered push has no error. Every other result
108108 // carries the status and reason, which is what the drainer
109109 // records on the queue row.
@@ -134,7 +134,7 @@ func TestSendTruncatesBodyOnRuneBoundary(t *testing.T) {
134134 // even offset, so a raw cut at maxBodyBytes is guaranteed to land on
135135 // the second byte of one of them rather than a rune boundary.
136136 long := "x" + strings.Repeat("é", 2000)
137 res, _, err := c.Send(context.Background(), "T", "u", "t", long, "p")
137 res, _, err := c.Send(context.Background(), "T", "u", 0, "t", long, "p")
138138 if err != nil || res != resultSent {
139139 t.Fatalf("res = %v, err = %v", res, err)
140140 }
@@ -186,7 +186,7 @@ func TestSendNamesTheAccount(t *testing.T) {
186186 })
187187 c.siteURL = "https://gitbay.org"
188188
189 if _, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc",
189 if _, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc", 2,
190190 "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil {
191191 t.Fatalf("Send: %v", err)
192192 }
@@ -200,4 +200,8 @@ func TestSendNamesTheAccount(t *testing.T) {
200200 if payload["path"] != "krz/gitbay/issues/12" {
201201 t.Fatalf("path = %v", payload["path"])
202202 }
203 aps := payload["aps"].(map[string]any)
204 if aps["badge"] != float64(2) {
205 t.Fatalf("badge = %v", aps["badge"])
206 }
203207}
internal/push/push.go +1 −1
@@ -49,7 +49,7 @@ func (d *Deliverer) drain(ctx context.Context) {
4949 return
5050 }
5151 for _, q := range due {
52 res, after, sendErr := d.Cl.Send(ctx, q.Token, q.Username, q.Title, q.Body, q.Path)
52 res, after, sendErr := d.Cl.Send(ctx, q.Token, q.Username, q.Badge, q.Title, q.Body, q.Path)
5353 msg := ""
5454 if sendErr != nil {
5555 msg = sendErr.Error()
internal/store/push.go +6 −2
@@ -128,6 +128,9 @@ type QueuedPush struct {
128128 Body string
129129 Path string
130130 Attempts int
131 // Badge is the recipient's unread inbox count, for the alert's badge.
132 // Counted here rather than at enqueue so a cleared inbox is reflected.
133 Badge int
131134}
132135
133136// EnqueuePush writes one row per registered device, and nothing when the
@@ -149,7 +152,8 @@ func (s *Store) EnqueuePush(userID int64, title, body, path string) error {
149152
150153func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
151154 rows, err := s.DB.Query(`
152 SELECT q.id, q.device_id, d.token, u.username, q.title, q.body, q.path, q.attempts
155 SELECT q.id, q.device_id, d.token, u.username, q.title, q.body, q.path, q.attempts,
156 (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL)
153157 FROM push_queue q
154158 JOIN push_devices d ON d.id = q.device_id
155159 JOIN users u ON u.id = d.user_id
@@ -163,7 +167,7 @@ func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
163167 var out []QueuedPush
164168 for rows.Next() {
165169 var p QueuedPush
166 if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Username, &p.Title, &p.Body, &p.Path, &p.Attempts); err != nil {
170 if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Username, &p.Title, &p.Body, &p.Path, &p.Attempts, &p.Badge); err != nil {
167171 return nil, err
168172 }
169173 out = append(out, p)
internal/store/push_test.go +37
@@ -275,3 +275,40 @@ func TestDuePushCarriesTheUsername(t *testing.T) {
275275 t.Fatalf("Username = %q, want alice", due[0].Username)
276276 }
277277}
278
279// The queue row carries the recipient's unread count, so the alert can
280// badge the app icon. Counted at send rather than at enqueue: an inbox
281// cleared in the seconds before delivery is reflected.
282func TestDuePushCarriesTheUnreadCount(t *testing.T) {
283 s := pushFixture(t)
284 uid, err := s.CreateUser("alice", false)
285 if err != nil {
286 t.Fatal(err)
287 }
288 repoID, err := s.CreateRepo("user", uid, "app", "public")
289 if err != nil {
290 t.Fatal(err)
291 }
292 if _, err := s.AddPushDevice(uid, "tok-a", "iphone"); err != nil {
293 t.Fatal(err)
294 }
295 // Two unread inbox rows, then a queued push.
296 for i := 0; i < 2; i++ {
297 if err := s.AddNotice(uid, repoID, "issue", "bob", "opened issue #1", "alice/app/issues/1"); err != nil {
298 t.Fatal(err)
299 }
300 }
301 if err := s.EnqueuePush(uid, "alice/app", "bob opened issue #1", "alice/app/issues/1"); err != nil {
302 t.Fatal(err)
303 }
304 due, err := s.DuePush(20)
305 if err != nil {
306 t.Fatal(err)
307 }
308 if len(due) != 1 {
309 t.Fatalf("want one queued push, got %d", len(due))
310 }
311 if due[0].Badge != 2 {
312 t.Fatalf("Badge = %d, want 2", due[0].Badge)
313 }
314}