Commit 72a0560c4d
Verified · cmc ci/build: success ci/test: success
CHANGELOG.org +6
| @@ -14,6 +14,12 @@ A push alert names the account it is for (#89). | ||
| 14 | 14 | =instance=, the instance's =site_url=, and =user=, the recipient's |
| 15 | 15 | username — together the account's identity. |
| 16 | 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 | 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 | 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 | 155 | | `notifications device list` | `ReadOnly`. Token shown truncated, never in full. | |
| 156 | 156 | | `notifications device remove <id>` | Own devices only. | |
| 157 | 157 | | `notifications settings push on\|off` | Joins `settings mail` and `settings watch`. | |
| @@ -205,6 +205,7 @@ The payload: | ||
| 205 | 205 | "aps": { |
| 206 | 206 | "alert": {"title": "krz/gitbay", "body": "cmc opened issue #12"}, |
| 207 | 207 | "sound": "default", |
| 208 | "badge": 3, | |
| 208 | 209 | "thread-id": "krz/gitbay" |
| 209 | 210 | }, |
| 210 | 211 | "path": "krz/gitbay/issues/12", |
| @@ -217,7 +218,9 @@ The payload: | ||
| 217 | 218 | string the inbox row carries, so the two surfaces cannot disagree. |
| 218 | 219 | `thread-id` groups a repository's notices in Notification Center. |
| 219 | 220 | `path` is the inbox row's `path` field, which the app already knows how |
| 220 | to turn into a link. | |
| 221 | to turn into a link. `badge` is the recipient's unread inbox count, | |
| 222 | counted by `DuePush` at send time rather than at enqueue, so a cleared | |
| 223 | inbox is reflected. | |
| 221 | 224 | |
| 222 | 225 | `instance` is this instance's `site_url` and `user` the recipient's |
| 223 | 226 | username. A device token is one install, and an install registers |
e2e/push_test.go +4
| @@ -133,6 +133,10 @@ environment = "production" | ||
| 133 | 133 | if inst, _ := got[0]["instance"].(string); !strings.HasPrefix(inst, "https://") { |
| 134 | 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 | 141 | // Apple retires the token. The next push reaps the device. |
| 138 | 142 | mu.Lock() |
internal/control/notifications.go +4 −3
| @@ -274,11 +274,12 @@ func runNotificationsDeviceAdd(c *Ctx, args []string) int { | ||
| 274 | 274 | if len(token) > maxDeviceTokenBytes { |
| 275 | 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 | 279 | return c.fail(protocol.ExitFailure, "%v", err) |
| 279 | 280 | } |
| 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) | |
| 282 | 283 | }) |
| 283 | 284 | } |
| 284 | 285 | |
internal/control/notifications_test.go +22
| @@ -2,6 +2,7 @@ package control | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | "fmt" | |
| 5 | 6 | "strings" |
| 6 | 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. | |
| 228 | func 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 | ||
| 224 | 246 | func TestNotificationsSettingsShowsPush(t *testing.T) { |
| 225 | 247 | c := notifTestCtx(t, "alice") |
| 226 | 248 | var out bytes.Buffer |
internal/push/apns.go +2 −1
| @@ -111,7 +111,7 @@ func loopbackHost(hostport string) bool { | ||
| 111 | 111 | |
| 112 | 112 | // Send delivers one alert. The returned duration is the server's |
| 113 | 113 | // Retry-After when it gave one, zero otherwise. |
| 114 | func (c *Client) Send(ctx context.Context, token, user, title, body, path string) (result, time.Duration, error) { | |
| 114 | func (c *Client) Send(ctx context.Context, token, user string, badge int, title, body, path string) (result, time.Duration, error) { | |
| 115 | 115 | if len(body) > maxBodyBytes { |
| 116 | 116 | // A raw byte cut can land mid-rune on multi-byte UTF-8 (emoji, |
| 117 | 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 | 123 | "aps": map[string]any{ |
| 124 | 124 | "alert": map[string]string{"title": title, "body": body}, |
| 125 | 125 | "sound": "default", |
| 126 | "badge": badge, | |
| 126 | 127 | "thread-id": title, |
| 127 | 128 | }, |
| 128 | 129 | "path": path, |
internal/push/apns_test.go +8 −4
| @@ -45,7 +45,7 @@ func TestSendShapesTheRequest(t *testing.T) { | ||
| 45 | 45 | json.Unmarshal(raw, &payload) |
| 46 | 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 | 49 | if err != nil || res != resultSent { |
| 50 | 50 | t.Fatalf("res = %v, err = %v", res, err) |
| 51 | 51 | } |
| @@ -103,7 +103,7 @@ func TestSendMapsResponses(t *testing.T) { | ||
| 103 | 103 | w.WriteHeader(tc.status) |
| 104 | 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 | 107 | // Only a delivered push has no error. Every other result |
| 108 | 108 | // carries the status and reason, which is what the drainer |
| 109 | 109 | // records on the queue row. |
| @@ -134,7 +134,7 @@ func TestSendTruncatesBodyOnRuneBoundary(t *testing.T) { | ||
| 134 | 134 | // even offset, so a raw cut at maxBodyBytes is guaranteed to land on |
| 135 | 135 | // the second byte of one of them rather than a rune boundary. |
| 136 | 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 | 138 | if err != nil || res != resultSent { |
| 139 | 139 | t.Fatalf("res = %v, err = %v", res, err) |
| 140 | 140 | } |
| @@ -186,7 +186,7 @@ func TestSendNamesTheAccount(t *testing.T) { | ||
| 186 | 186 | }) |
| 187 | 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 | 190 | "krz/gitbay", "cmc opened issue #12", "krz/gitbay/issues/12"); err != nil { |
| 191 | 191 | t.Fatalf("Send: %v", err) |
| 192 | 192 | } |
| @@ -200,4 +200,8 @@ func TestSendNamesTheAccount(t *testing.T) { | ||
| 200 | 200 | if payload["path"] != "krz/gitbay/issues/12" { |
| 201 | 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 | 49 | return |
| 50 | 50 | } |
| 51 | 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 | 53 | msg := "" |
| 54 | 54 | if sendErr != nil { |
| 55 | 55 | msg = sendErr.Error() |
internal/store/push.go +6 −2
| @@ -128,6 +128,9 @@ type QueuedPush struct { | ||
| 128 | 128 | Body string |
| 129 | 129 | Path string |
| 130 | 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 | 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 | |
| 150 | 153 | func (s *Store) DuePush(limit int) ([]QueuedPush, error) { |
| 151 | 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 | 157 | FROM push_queue q |
| 154 | 158 | JOIN push_devices d ON d.id = q.device_id |
| 155 | 159 | JOIN users u ON u.id = d.user_id |
| @@ -163,7 +167,7 @@ func (s *Store) DuePush(limit int) ([]QueuedPush, error) { | ||
| 163 | 167 | var out []QueuedPush |
| 164 | 168 | for rows.Next() { |
| 165 | 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 | 171 | return nil, err |
| 168 | 172 | } |
| 169 | 173 | out = append(out, p) |
internal/store/push_test.go +37
| @@ -275,3 +275,40 @@ func TestDuePushCarriesTheUsername(t *testing.T) { | ||
| 275 | 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. | |
| 282 | func 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 | } | |