Commit 72a0560c4d

72a0560c4d020f13b38349fb9390d40343867077

parent: 66304b6cd8

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-20 21:24 UTC

push: return the device id and badge the alert

device add returned only a status, so a client that wants to
deregister had to list devices and match its own token against the
truncated display value. It now returns the id AddPushDevice already
had.

The alert carries the recipient's unread count, counted in DuePush at
send rather than at enqueue so a cleared inbox is reflected.

Ref #89
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}