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).
14 =instance=, the instance's =site_url=, and =user=, the recipient's 14 =instance=, the instance's =site_url=, and =user=, the recipient's
15 username — together the account's identity. 15 username — together the account's identity.
16- =DuePush= joins =users= for the recipient; no schema change. 16- =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.
17 23
18* v1.32.0 — 2026-09-20 24* v1.32.0 — 2026-09-20
19 25
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.
151 151
152| Command | Notes | 152| Command | Notes |
153|---|---| 153|---|---|
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. |
155| `notifications device list` | `ReadOnly`. Token shown truncated, never in full. | 155| `notifications device list` | `ReadOnly`. Token shown truncated, never in full. |
156| `notifications device remove <id>` | Own devices only. | 156| `notifications device remove <id>` | Own devices only. |
157| `notifications settings push on\|off` | Joins `settings mail` and `settings watch`. | 157| `notifications settings push on\|off` | Joins `settings mail` and `settings watch`. |
@@ -205,6 +205,7 @@ The payload:
205 "aps": { 205 "aps": {
206 "alert": {"title": "krz/gitbay", "body": "cmc opened issue #12"}, 206 "alert": {"title": "krz/gitbay", "body": "cmc opened issue #12"},
207 "sound": "default", 207 "sound": "default",
208 "badge": 3,
208 "thread-id": "krz/gitbay" 209 "thread-id": "krz/gitbay"
209 }, 210 },
210 "path": "krz/gitbay/issues/12", 211 "path": "krz/gitbay/issues/12",
@@ -217,7 +218,9 @@ The payload:
217string the inbox row carries, so the two surfaces cannot disagree. 218string the inbox row carries, so the two surfaces cannot disagree.
218`thread-id` groups a repository's notices in Notification Center. 219`thread-id` groups a repository's notices in Notification Center.
219`path` is the inbox row's `path` field, which the app already knows how 220`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.
221 224
222`instance` is this instance's `site_url` and `user` the recipient's 225`instance` is this instance's `site_url` and `user` the recipient's
223username. A device token is one install, and an install registers 226username. A device token is one install, and an install registers
e2e/push_test.go +4
@@ -133,6 +133,10 @@ environment = "production"
133 if inst, _ := got[0]["instance"].(string); !strings.HasPrefix(inst, "https://") { 133 if inst, _ := got[0]["instance"].(string); !strings.HasPrefix(inst, "https://") {
134 t.Fatalf("instance = %v, want this instance's site_url", inst) 134 t.Fatalf("instance = %v, want this instance's site_url", inst)
135 } 135 }
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 }
136 140
137 // Apple retires the token. The next push reaps the device. 141 // Apple retires the token. The next push reaps the device.
138 mu.Lock() 142 mu.Lock()
internal/control/notifications.go +4 −3
@@ -274,11 +274,12 @@ func runNotificationsDeviceAdd(c *Ctx, args []string) int {
274 if len(token) > maxDeviceTokenBytes { 274 if len(token) > maxDeviceTokenBytes {
275 return c.fail(protocol.ExitUsage, "device token is too long") 275 return c.fail(protocol.ExitUsage, "device token is too long")
276 } 276 }
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 {
278 return c.fail(protocol.ExitFailure, "%v", err) 279 return c.fail(protocol.ExitFailure, "%v", err)
279 } 280 }
280 return c.emit(map[string]string{"status": "registered"}, func(w io.Writer) { 281 return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) {
281 fmt.Fprintln(w, "device registered") 282 fmt.Fprintf(w, "device %d registered\n", id)
282 }) 283 })
283} 284}
284 285
internal/control/notifications_test.go +22
@@ -2,6 +2,7 @@ package control
2 2
3import ( 3import (
4 "bytes" 4 "bytes"
5 "fmt"
5 "strings" 6 "strings"
6 "testing" 7 "testing"
7 8
@@ -221,6 +222,27 @@ func TestNotificationsDeviceListMasksAShortToken(t *testing.T) {
221 } 222 }
222} 223}
223 224
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
224func TestNotificationsSettingsShowsPush(t *testing.T) { 246func TestNotificationsSettingsShowsPush(t *testing.T) {
225 c := notifTestCtx(t, "alice") 247 c := notifTestCtx(t, "alice")
226 var out bytes.Buffer 248 var out bytes.Buffer
internal/push/apns.go +2 −1
@@ -111,7 +111,7 @@ func loopbackHost(hostport string) bool {
111 111
112// Send delivers one alert. The returned duration is the server's 112// Send delivers one alert. The returned duration is the server's
113// Retry-After when it gave one, zero otherwise. 113// 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) {
115 if len(body) > maxBodyBytes { 115 if len(body) > maxBodyBytes {
116 // A raw byte cut can land mid-rune on multi-byte UTF-8 (emoji, 116 // A raw byte cut can land mid-rune on multi-byte UTF-8 (emoji,
117 // accents, non-Latin usernames). ToValidUTF8 drops the 117 // accents, non-Latin usernames). ToValidUTF8 drops the
@@ -123,6 +123,7 @@ func (c *Client) Send(ctx context.Context, token, user, title, body, path string
123 "aps": map[string]any{ 123 "aps": map[string]any{
124 "alert": map[string]string{"title": title, "body": body}, 124 "alert": map[string]string{"title": title, "body": body},
125 "sound": "default", 125 "sound": "default",
126 "badge": badge,
126 "thread-id": title, 127 "thread-id": title,
127 }, 128 },
128 "path": path, 129 "path": path,
internal/push/apns_test.go +8 −4
@@ -45,7 +45,7 @@ func TestSendShapesTheRequest(t *testing.T) {
45 json.Unmarshal(raw, &payload) 45 json.Unmarshal(raw, &payload)
46 w.WriteHeader(200) 46 w.WriteHeader(200)
47 }) 47 })
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")
49 if err != nil || res != resultSent { 49 if err != nil || res != resultSent {
50 t.Fatalf("res = %v, err = %v", res, err) 50 t.Fatalf("res = %v, err = %v", res, err)
51 } 51 }
@@ -103,7 +103,7 @@ func TestSendMapsResponses(t *testing.T) {
103 w.WriteHeader(tc.status) 103 w.WriteHeader(tc.status)
104 io.WriteString(w, tc.body) 104 io.WriteString(w, tc.body)
105 }) 105 })
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")
107 // Only a delivered push has no error. Every other result 107 // Only a delivered push has no error. Every other result
108 // carries the status and reason, which is what the drainer 108 // carries the status and reason, which is what the drainer
109 // records on the queue row. 109 // records on the queue row.
@@ -134,7 +134,7 @@ func TestSendTruncatesBodyOnRuneBoundary(t *testing.T) {
134 // even offset, so a raw cut at maxBodyBytes is guaranteed to land on 134 // even offset, so a raw cut at maxBodyBytes is guaranteed to land on
135 // the second byte of one of them rather than a rune boundary. 135 // the second byte of one of them rather than a rune boundary.
136 long := "x" + strings.Repeat("é", 2000) 136 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")
138 if err != nil || res != resultSent { 138 if err != nil || res != resultSent {
139 t.Fatalf("res = %v, err = %v", res, err) 139 t.Fatalf("res = %v, err = %v", res, err)
140 } 140 }
@@ -186,7 +186,7 @@ func TestSendNamesTheAccount(t *testing.T) {
186 }) 186 })
187 c.siteURL = "https://gitbay.org" 187 c.siteURL = "https://gitbay.org"
188 188
189 if _, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc", 189 if _, _, err := c.Send(context.Background(), "DEVTOKEN", "cmc", 2,
190 "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil { 190 "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil {
191 t.Fatalf("Send: %v", err) 191 t.Fatalf("Send: %v", err)
192 } 192 }
@@ -200,4 +200,8 @@ func TestSendNamesTheAccount(t *testing.T) {
200 if payload["path"] != "krz/gitbay/issues/12" { 200 if payload["path"] != "krz/gitbay/issues/12" {
201 t.Fatalf("path = %v", payload["path"]) 201 t.Fatalf("path = %v", payload["path"])
202 } 202 }
203 aps := payload["aps"].(map[string]any)
204 if aps["badge"] != float64(2) {
205 t.Fatalf("badge = %v", aps["badge"])
206 }
203} 207}
internal/push/push.go +1 −1
@@ -49,7 +49,7 @@ func (d *Deliverer) drain(ctx context.Context) {
49 return 49 return
50 } 50 }
51 for _, q := range due { 51 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)
53 msg := "" 53 msg := ""
54 if sendErr != nil { 54 if sendErr != nil {
55 msg = sendErr.Error() 55 msg = sendErr.Error()
internal/store/push.go +6 −2
@@ -128,6 +128,9 @@ type QueuedPush struct {
128 Body string 128 Body string
129 Path string 129 Path string
130 Attempts int 130 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
131} 134}
132 135
133// EnqueuePush writes one row per registered device, and nothing when the 136// 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 {
149 152
150func (s *Store) DuePush(limit int) ([]QueuedPush, error) { 153func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
151 rows, err := s.DB.Query(` 154 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)
153 FROM push_queue q 157 FROM push_queue q
154 JOIN push_devices d ON d.id = q.device_id 158 JOIN push_devices d ON d.id = q.device_id
155 JOIN users u ON u.id = d.user_id 159 JOIN users u ON u.id = d.user_id
@@ -163,7 +167,7 @@ func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
163 var out []QueuedPush 167 var out []QueuedPush
164 for rows.Next() { 168 for rows.Next() {
165 var p QueuedPush 169 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 {
167 return nil, err 171 return nil, err
168 } 172 }
169 out = append(out, p) 173 out = append(out, p)
internal/store/push_test.go +37
@@ -275,3 +275,40 @@ func TestDuePushCarriesTheUsername(t *testing.T) {
275 t.Fatalf("Username = %q, want alice", due[0].Username) 275 t.Fatalf("Username = %q, want alice", due[0].Username)
276 } 276 }
277} 277}
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}