| @@ -1,6 +1,7 @@ |
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "errors" |
| 4 | 5 | "fmt" |
| 5 | 6 | "io" |
| 6 | 7 | "strconv" |
| @@ -30,6 +31,22 @@ func init() { |
| 30 | 31 | register(Command{Path: []string{"notifications", "settings", "watch"}, |
| 31 | 32 | Summary: "every issue and merge request on repositories you can write to", |
| 32 | 33 | Usage: "notifications settings watch on|off", Run: runNotificationsSettingsWatch}) |
| 34 | register(Command{Path: []string{"notifications", "device", "add"}, |
| 35 | Summary: "register an Apple device for push, token on stdin", |
| 36 | Usage: "notifications device add [--label <name>] < token", |
| 37 | // Mandatory: without it control.go swaps in an empty reader and |
| 38 | // this command stores an empty token without erroring. |
| 39 | ReadsStdin: true, Run: runNotificationsDeviceAdd}) |
| 40 | register(Command{Path: []string{"notifications", "device", "list"}, |
| 41 | Summary: "your registered devices", |
| 42 | Usage: "notifications device list", |
| 43 | ReadOnly: true, Run: runNotificationsDeviceList}) |
| 44 | register(Command{Path: []string{"notifications", "device", "remove"}, |
| 45 | Summary: "deregister a device", |
| 46 | Usage: "notifications device remove <id>", Run: runNotificationsDeviceRemove}) |
| 47 | register(Command{Path: []string{"notifications", "settings", "push"}, |
| 48 | Summary: "activity on your registered devices as well as the inbox", |
| 49 | Usage: "notifications settings push on|off", Run: runNotificationsSettingsPush}) |
| 33 | 50 | register(Command{Path: []string{"repo", "watch"}, |
| 34 | 51 | Summary: "hear about all activity on a repository", |
| 35 | 52 | Usage: "repo watch <owner/name>", Run: runRepoWatch}) |
| @@ -155,14 +172,18 @@ func emitNotificationSettings(c *Ctx) int { |
| 155 | 172 | if err != nil { |
| 156 | 173 | return c.fail(protocol.ExitFailure, "%v", err) |
| 157 | 174 | } |
| 158 | | return c.emit(map[string]bool{"mail": mail, "watch": watch}, func(w io.Writer) { |
| 175 | push, err := c.Store.PushEnabled(c.User.ID) |
| 176 | if err != nil { |
| 177 | return c.fail(protocol.ExitFailure, "%v", err) |
| 178 | } |
| 179 | return c.emit(map[string]bool{"mail": mail, "watch": watch, "push": push}, func(w io.Writer) { |
| 159 | 180 | onOff := func(on bool) string { |
| 160 | 181 | if on { |
| 161 | 182 | return "on" |
| 162 | 183 | } |
| 163 | 184 | return "off" |
| 164 | 185 | } |
| 165 | | fmt.Fprintf(w, "mail: %s\nwatch: %s\n", onOff(mail), onOff(watch)) |
| 186 | fmt.Fprintf(w, "mail: %s\nwatch: %s\npush: %s\n", onOff(mail), onOff(watch), onOff(push)) |
| 166 | 187 | }) |
| 167 | 188 | } |
| 168 | 189 | |
| @@ -198,6 +219,101 @@ func runNotificationsSettingsWatch(c *Ctx, args []string) int { |
| 198 | 219 | return emitNotificationSettings(c) |
| 199 | 220 | } |
| 200 | 221 | |
| 222 | func runNotificationsSettingsPush(c *Ctx, args []string) int { |
| 223 | if len(args) != 1 || (args[0] != "on" && args[0] != "off") { |
| 224 | return c.usage() |
| 225 | } |
| 226 | if err := c.Store.SetPushEnabled(c.User.ID, args[0] == "on"); err != nil { |
| 227 | return c.fail(protocol.ExitFailure, "%v", err) |
| 228 | } |
| 229 | return emitNotificationSettings(c) |
| 230 | } |
| 231 | |
| 232 | // maxDeviceTokenBytes is well past APNs' 32-byte token rendered as 64 hex |
| 233 | // characters, and stops a stdin that is not a token from becoming a row. |
| 234 | const maxDeviceTokenBytes = 512 |
| 235 | |
| 236 | func runNotificationsDeviceAdd(c *Ctx, args []string) int { |
| 237 | f, err := parseFlags(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage}) |
| 238 | if err != nil { |
| 239 | return c.fail(protocol.ExitUsage, "%v", err) |
| 240 | } |
| 241 | if len(f.Pos) != 0 { |
| 242 | return c.usage() |
| 243 | } |
| 244 | raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxDeviceTokenBytes+1)) |
| 245 | if err != nil { |
| 246 | return c.fail(protocol.ExitFailure, "reading stdin: %v", err) |
| 247 | } |
| 248 | token := strings.TrimSpace(string(raw)) |
| 249 | if token == "" { |
| 250 | return c.usageWith("no device token on stdin") |
| 251 | } |
| 252 | if len(token) > maxDeviceTokenBytes { |
| 253 | return c.fail(protocol.ExitUsage, "device token is too long") |
| 254 | } |
| 255 | if _, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label")); err != nil { |
| 256 | return c.fail(protocol.ExitFailure, "%v", err) |
| 257 | } |
| 258 | return c.emit(map[string]string{"status": "registered"}, func(w io.Writer) { |
| 259 | fmt.Fprintln(w, "device registered") |
| 260 | }) |
| 261 | } |
| 262 | |
| 263 | func runNotificationsDeviceList(c *Ctx, args []string) int { |
| 264 | if len(args) != 0 { |
| 265 | return c.usage() |
| 266 | } |
| 267 | devices, err := c.Store.PushDevices(c.User.ID) |
| 268 | if err != nil { |
| 269 | return c.fail(protocol.ExitFailure, "%v", err) |
| 270 | } |
| 271 | type row struct { |
| 272 | ID int64 `json:"id"` |
| 273 | Label string `json:"label"` |
| 274 | Token string `json:"token"` // truncated; a token is not echoed in full |
| 275 | Added string `json:"added"` |
| 276 | } |
| 277 | rows := make([]row, 0, len(devices)) |
| 278 | for _, d := range devices { |
| 279 | rows = append(rows, row{ID: d.ID, Label: d.Label, |
| 280 | Token: shortToken(d.Token), Added: d.CreatedAt}) |
| 281 | } |
| 282 | return c.emit(rows, func(w io.Writer) { |
| 283 | for _, r := range rows { |
| 284 | fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", r.ID, r.Label, r.Token, r.Added) |
| 285 | } |
| 286 | }) |
| 287 | } |
| 288 | |
| 289 | // shortToken renders a device token as its first eight characters. Enough |
| 290 | // to tell two devices apart in a list, not enough to push to one. |
| 291 | func shortToken(t string) string { |
| 292 | if len(t) <= 8 { |
| 293 | return t |
| 294 | } |
| 295 | return t[:8] + "…" |
| 296 | } |
| 297 | |
| 298 | func runNotificationsDeviceRemove(c *Ctx, args []string) int { |
| 299 | if len(args) != 1 { |
| 300 | return c.usage() |
| 301 | } |
| 302 | id, err := strconv.ParseInt(args[0], 10, 64) |
| 303 | if err != nil { |
| 304 | return c.usageWith("device id must be a number") |
| 305 | } |
| 306 | if err := c.Store.RemovePushDevice(c.User.ID, id); err != nil { |
| 307 | if errors.Is(err, store.ErrNotFound) { |
| 308 | return c.fail(protocol.ExitNotFound, "no such device; notifications device list shows yours") |
| 309 | } |
| 310 | return c.fail(protocol.ExitFailure, "%v", err) |
| 311 | } |
| 312 | return c.emit(map[string]string{"status": "removed"}, func(w io.Writer) { |
| 313 | fmt.Fprintln(w, "device removed") |
| 314 | }) |
| 315 | } |
| 316 | |
| 201 | 317 | // noticesDefaultLimit caps a bare list; pagination reaches further back. |
| 202 | 318 | const noticesDefaultLimit = 50 |
| 203 | 319 | |