diff --git a/README.md b/README.md index 9da9a92..7764c6f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,13 @@ to it yet. device, and go when the roster drops them or Apple says they are gone. Apple's key comes from Parameter Store ([deploy/apns.md](deploy/apns.md)). +- **Widget push.** A widget registers its push token with `push.register + {kind: widgets}`, which needs `ftw.live.read`. When a box's prices reach + further than before, as when tomorrow's arrive, or a car is plugged in, + the service sends Apple's widgets push: no data, only "content changed". + The widgets then read `/v1/now`. At most 4 an hour per home, since Apple + budgets widget reloads. A device holds at most 4 tokens of each kind; a + new one pushes out its oldest. - **Widget token.** A device asks for its extension token over its app session with `token.get`. A widget reads `GET /v1/now` with it: the newest `live`, `prices` and `hb` as the box signed them, and nothing else. The @@ -61,7 +68,7 @@ Not built yet: | Step | What | Where | |---|---|---| -| C6 | Tiles, widget push | `internal/server`, `internal/push` | +| C6 | Tiles | `internal/server` | | C7–C8 | Statistics | not started | ## How it holds a home diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index edb78eb..be2e62c 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -102,8 +102,9 @@ func serve(args []string) error { srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Log: log} // Alerts: the box's signed events to its phones through Apple, and - // box.unreachable when a box is away. Without a key the service keeps - // registrations and sends nothing. + // box.unreachable when a box is away. Widgets: a push with no data when + // prices or a car change. Without a key the service keeps registrations + // and sends nothing. var sender push.Sender switch client, err := loadAPNs(*apnsDir, *apnsTopic); { case err != nil: @@ -118,7 +119,7 @@ func serve(args []string) error { sender = client } pushSvc := push.New(reg, sender, log) - up.OnEvent = pushSvc.Event + up.OnHeld = pushSvc.Held up.OnRoster = func(home string, r *cloudwire.Roster) { pushSvc.Roster(home, r) tokens.Roster(home, r) diff --git a/deploy/apns.md b/deploy/apns.md index 2ec8968..5a0d347 100644 --- a/deploy/apns.md +++ b/deploy/apns.md @@ -1,7 +1,7 @@ # Apple's push key -`ftwcloudd` sends alerts through APNs with a token key (`.p8`) from Sourceful's -Apple team. Only the owner handles the key: he makes it at Apple and puts it +`ftwcloudd` sends alerts and widget pushes through APNs with a token key +(`.p8`) from Sourceful's Apple team. Only the owner handles the key: he makes it at Apple and puts it in AWS Systems Manager Parameter Store himself. The host reads it from there into tmpfs at every boot. It is never in the repository, the image, the volume or a log. @@ -12,13 +12,15 @@ or a log. Certificates, Identifiers & Profiles → **Keys** → **+**. 2. Name it `FTW Cloud APNs`, tick **Apple Push Notifications service (APNs)**, and choose **Configure**: environment **Sandbox & Production**; key - restriction **Topic Specific** with `energy.ftw.app`, or **Team Scoped**. + restriction **Team Scoped**, or **Topic Specific** with `energy.ftw.app` + and `energy.ftw.app.push-type.widgets`, the topic widget pushes go to. Save, then **Continue** → **Register**. 3. **Download** `AuthKey_.p8`. Apple lets you download it once. Note the 10-character **Key ID**. The Team ID is `L66M368PZ6`. 4. Under **Identifiers** → `energy.ftw.app`, check that **Push Notifications** and **Time Sensitive Notifications** are on. Xcode turns them on the first - time it signs the app with the new entitlements. + time it signs the app with the new entitlements. The widget extension, + `energy.ftw.app.widgets`, needs **Push Notifications** too. ## 2. Put it in Parameter Store (owner, once) diff --git a/internal/apns/apns.go b/internal/apns/apns.go index d16b37a..1870054 100644 --- a/internal/apns/apns.go +++ b/internal/apns/apns.go @@ -3,8 +3,8 @@ // by the team's .p8 key) that is renewed every 40 minutes. // // It knows nothing about homes or events. The caller hands it a device -// token and the payload bytes, and gets back what Apple said, including -// whether the token is gone for good. +// token, the app, the push type and the payload bytes, and gets back what +// Apple said, including whether the token is gone for good. package apns import ( @@ -41,6 +41,16 @@ const ( Production = "production" ) +// Push types. An alert shows on the phone; a widgets push carries no data +// and tells WidgetKit to reload the app's widgets, which Apple budgets. +const ( + PushAlert = "alert" + PushWidgets = "widgets" +) + +// widgetsTopicSuffix follows the app's bundle id in a widgets push's topic. +const widgetsTopicSuffix = ".push-type.widgets" + // TokenLife is how long one provider token is used. Apple refuses a token // older than an hour and one renewed more often than every 20 minutes. const TokenLife = 40 * time.Minute @@ -55,7 +65,8 @@ type Config struct { // KeyID is the key's 10-character id; TeamID the team's. KeyID string TeamID string - // Topic is the app's bundle id. + // Topic is the iPhone app's bundle id, for a notification that names + // no app. Topic string // Seams for tests. Production leaves them zero. @@ -130,7 +141,12 @@ type Notification struct { Environment string // DeviceToken is the token the phone got from iOS. DeviceToken []byte - Payload []byte + // App is the bundle id of the app the token belongs to; empty is the + // iPhone app, Config.Topic. + App string + // Type is PushAlert, the default when empty, or PushWidgets. + Type string + Payload []byte // Expiration is when Apple may stop trying to deliver. Zero means // deliver now or not at all. Expiration time.Time @@ -151,9 +167,18 @@ type Result struct { // OK reports whether Apple took the push. func (r Result) OK() bool { return r.Status == http.StatusOK } -// Send posts one alert. Every alert has the same priority and push type, -// and no collapse id, so nothing in the request says what kind it is. +// Send posts one push. Every alert has the same priority and push type, and +// no collapse id, so nothing in the request says what kind of event it +// carries. A widgets push goes to the app's widgets topic with Apple's +// default priority. func (c *Client) Send(ctx context.Context, n Notification) (Result, error) { + switch n.Type { + case "": + n.Type = PushAlert + case PushAlert, PushWidgets: + default: + return Result{}, fmt.Errorf("apns: unknown push type %q", n.Type) + } base := c.cfg.ProductionURL switch n.Environment { case Production: @@ -185,10 +210,18 @@ func (c *Client) post(ctx context.Context, base string, n Notification, renew bo if err != nil { return Result{}, err } + topic := n.App + if topic == "" { + topic = c.cfg.Topic + } req.Header.Set("authorization", "bearer "+token) - req.Header.Set("apns-topic", c.cfg.Topic) - req.Header.Set("apns-push-type", "alert") - req.Header.Set("apns-priority", "10") + req.Header.Set("apns-push-type", n.Type) + if n.Type == PushWidgets { + req.Header.Set("apns-topic", topic+widgetsTopicSuffix) + } else { + req.Header.Set("apns-topic", topic) + req.Header.Set("apns-priority", "10") + } exp := int64(0) if !n.Expiration.IsZero() { exp = n.Expiration.Unix() diff --git a/internal/apns/apns_test.go b/internal/apns/apns_test.go index 42fe07a..8f94a61 100644 --- a/internal/apns/apns_test.go +++ b/internal/apns/apns_test.go @@ -234,3 +234,37 @@ func TestOnlyAnAppleKeyIsTaken(t *testing.T) { t.Fatal("sent to an environment that does not exist") } } + +// A widgets push goes to the app's widgets topic with Apple's own push type +// and default priority; a token from another app names that app's topic. +func TestAWidgetsPushUsesTheWidgetsTopic(t *testing.T) { + p8, key := testP8(t) + apple := newFakeApple(t, &key.PublicKey) + now := time.Unix(1_791_820_800, 0) + c := apple.client(t, p8, func() time.Time { return now }) + payload := []byte(`{"aps":{"content-changed":true}}`) + for _, n := range []Notification{ + {Environment: Production, DeviceToken: []byte{1}, Type: PushWidgets, Payload: payload}, + {Environment: Production, DeviceToken: []byte{2}, Type: PushWidgets, App: "energy.ftw.app.watchkitapp", Payload: payload}, + {Environment: Production, DeviceToken: []byte{3}, App: "energy.ftw.app.watchkitapp", Payload: payload}, + } { + if res, err := c.Send(context.Background(), n); err != nil || !res.OK() { + t.Fatalf("send: %+v %v", res, err) + } + } + for i, want := range []struct{ topic, kind, priority string }{ + {"energy.ftw.app.push-type.widgets", "widgets", ""}, + {"energy.ftw.app.watchkitapp.push-type.widgets", "widgets", ""}, + {"energy.ftw.app.watchkitapp", "alert", "10"}, + } { + r := apple.reqs[i] + if r.Header.Get("apns-topic") != want.topic || r.Header.Get("apns-push-type") != want.kind || + r.Header.Get("apns-priority") != want.priority { + t.Errorf("push %d: topic %q, type %q, priority %q", i, r.Header.Get("apns-topic"), + r.Header.Get("apns-push-type"), r.Header.Get("apns-priority")) + } + } + if _, err := c.Send(context.Background(), Notification{Environment: Production, DeviceToken: []byte{4}, Type: "background"}); err == nil { + t.Fatal("an unknown push type was sent") + } +} diff --git a/internal/push/push.go b/internal/push/push.go index 772f3ed..149027a 100644 --- a/internal/push/push.go +++ b/internal/push/push.go @@ -11,11 +11,17 @@ // saying it was restarting on purpose. It carries no arguments and no // buttons. // -// Tokens are bound to roster entries. Only a device whose role and the -// owner's grant both allow ftw.events.read may register, and a roster that -// drops the device, moves its epoch or narrows its scope drops its tokens. -// A device an owner has suspended gets no alerts until the suspension ends. -// Nothing here logs a token or a home id. +// A widgets push carries no data. It tells WidgetKit to reload the app's +// widgets, which then read GET /v1/now with their own token. It goes when +// a box's priced hours reach further than before, as when tomorrow's prices +// arrive, and when a car is plugged in. +// +// Tokens are bound to roster entries. A device registers for alerts only if +// its role and the owner's grant both allow ftw.events.read, and for widgets +// only if they allow ftw.live.read. A roster that drops the device, moves +// its epoch or narrows its scope drops its tokens. A device an owner has +// suspended gets nothing until the suspension ends. Nothing here logs a +// token or a home id. package push import ( @@ -37,26 +43,57 @@ const ( // raises a few notifications a day; past this, something is wrong and // the rest are dropped. MaxAlertsPerHour = 20 + // MaxTokensPerDevice bounds a device's registrations of one kind. A + // phone holds one token per kind; a few more cover a reinstall before + // Apple says the old token is gone. Past it the oldest goes, so one + // device cannot fill the table or make a push call Apple without end. + MaxTokensPerDevice = 4 + // MaxWidgetPushesPerHour bounds a home's widget pushes. Prices arrive + // once a day and a car is plugged in a few times; Apple budgets widget + // reloads, so a car that flaps between plugged and not must not spend + // the budget. + MaxWidgetPushesPerHour = 4 // eventExpiry is how long Apple may keep trying to deliver an event. // Later than that the phone would show only the generic line. eventExpiry = cloudwire.AlertMaxAgeMs * time.Millisecond // unreachableExpiry is the same for box.unreachable, which stays true - // while the box is away. + // while the box is away, and for a widgets push, which stays true until + // the next change. unreachableExpiry = time.Hour + widgetsExpiry = time.Hour // queueSize bounds the alerts waiting to be sent. queueSize = 256 // expireEvery is how often registrations of long-silent homes go. expireEvery = time.Hour ) +// KindWidgets registers a widget's push token. cloudwire names only +// PushKindAlert so far; the protocol's Push section names widgets. +const KindWidgets = "widgets" + +// widgetsPayload is the whole widgets push, as Apple defines it. +var widgetsPayload = []byte(`{"aps":{"content-changed":true}}`) + +// kind is what one kind of registration needs and how it goes to Apple. +type kind struct { + scope string + pushType string + perHour int +} + +var kinds = map[string]kind{ + cloudwire.PushKindAlert: {protocol.ScopeEventsRead, apns.PushAlert, MaxAlertsPerHour}, + KindWidgets: {protocol.ScopeLiveRead, apns.PushWidgets, MaxWidgetPushesPerHour}, +} + // Sender is Apple. apns.Client is one. type Sender interface { Send(ctx context.Context, n apns.Notification) (apns.Result, error) } // ErrNotForThisDevice refuses a registration the device may not make: a -// viewer, a grant without events, or a kind that does not exist yet. The -// app session goes on. +// device without the scope its kind needs, or a kind that does not exist +// yet. The app session goes on. var ErrNotForThisDevice = errors.New("push: not a registration this device may make") // Service keeps registrations and sends alerts. @@ -76,7 +113,7 @@ type Service struct { } // homeState is one home's box connection, as the dead man's switch sees it, -// and its recent alerts. +// its recent pushes per kind, and what its widgets show that can change. type homeState struct { online bool // gen moves on every connect and disconnect, so a timer set for one @@ -84,7 +121,12 @@ type homeState struct { gen uint64 stop func() bool told bool - sent []time.Time + sent map[string][]time.Time + // pricedUntil is where the newest prices end, 0 before the first. + pricedUntil int64 + // plugged is each loadpoint's plug in the newest lp, nil before the + // first. + plugged map[string]bool } // New builds the service. A nil sender keeps registrations and sends @@ -139,13 +181,17 @@ func (s *Service) enqueue(job func(context.Context)) { // Register keeps a device's APNs token. dev is the roster entry the phone's // Noise key matched and scopes its effective scope, both from store.For. func (s *Service) Register(home string, dev cloudwire.Device, scopes []string, r cloudwire.PushRegister) error { - if r.Kind != cloudwire.PushKindAlert || !slices.Contains(scopes, protocol.ScopeEventsRead) || + k, known := kinds[r.Kind] + if !known || !slices.Contains(scopes, k.scope) || len(r.APNsToken) == 0 || len(r.APNsToken) > cloudwire.APNsTokenMaxBytes || (r.Environment != cloudwire.PushSandbox && r.Environment != cloudwire.PushProduction) { return ErrNotForThisDevice } - return s.reg.PushRegister(registry.PushReg{Home: home, DeviceID: dev.ID, Epoch: dev.Epoch, - Token: r.APNsToken, Environment: r.Environment, Kind: r.Kind, At: s.now()}) + if err := s.reg.PushRegister(registry.PushReg{Home: home, DeviceID: dev.ID, Epoch: dev.Epoch, + Token: r.APNsToken, Environment: r.Environment, Kind: r.Kind, At: s.now()}); err != nil { + return err + } + return s.reg.PushTrim(home, dev.ID, r.Kind, MaxTokensPerDevice) } // Unregister forgets one of the device's tokens. @@ -158,25 +204,39 @@ func (s *Service) Unregister(home string, dev cloudwire.Device, r cloudwire.Push // -------------------------------------------------------------------------- // Roster drops the tokens of devices the box's roster no longer lets read -// events: gone, paired again under a new epoch, or narrowed. +// what their kind carries: gone, paired again under a new epoch, or +// narrowed. func (s *Service) Roster(home string, r *cloudwire.Roster) { if r == nil { return } - allowed := map[string]uint64{} + devices := make(map[string]cloudwire.Device, len(r.Devices)) for _, d := range r.Devices { - if slices.Contains(d.Scopes, protocol.ScopeEventsRead) && slices.Contains(r.Grant.Scopes, protocol.ScopeEventsRead) { - allowed[d.ID] = d.Epoch - } + devices[d.ID] = d } - if err := s.reg.PushPrune(home, func(id string, epoch uint64) bool { - e, ok := allowed[id] - return ok && e == epoch + if err := s.reg.PushPrune(home, func(id string, epoch uint64, kindName string) bool { + d, ok := devices[id] + k, known := kinds[kindName] + return ok && known && d.Epoch == epoch && slices.Contains(d.Scopes, k.scope) && slices.Contains(r.Grant.Scopes, k.scope) }); err != nil { s.log.Warn("could not drop the tokens a roster dropped", "err", err) } } +// Held gets every envelope the service holds, after the uplink checked the +// box's signature: an event goes out as an alert, and new prices or a car +// plugged in send a widgets push. +func (s *Service) Held(home, class string, envelope []byte) { + switch class { + case cloudwire.MsgEvent: + s.Event(home, envelope) + case cloudwire.MsgPrices, cloudwire.MsgLP: + if s.widgetsChanged(home, class, envelope) { + s.enqueue(func(ctx context.Context) { s.send(ctx, home, KindWidgets, widgetsPayload, widgetsExpiry) }) + } + } +} + // Event sends a box-signed event envelope, as it arrived, to the home's // registered phones. func (s *Service) Event(home string, envelope []byte) { @@ -184,7 +244,60 @@ func (s *Service) Event(home string, envelope []byte) { if err != nil { return } - s.enqueue(func(ctx context.Context) { s.send(ctx, home, payload, eventExpiry) }) + s.enqueue(func(ctx context.Context) { s.send(ctx, home, cloudwire.PushKindAlert, payload, eventExpiry) }) +} + +// widgetsChanged reports whether prices now reach further than before, or a +// loadpoint is plugged in that was not. The first of each after a restart +// only sets what the next is compared with. +func (s *Service) widgetsChanged(home, class string, envelope []byte) bool { + env, err := cloudwire.DecodeEnvelope(envelope) + if err != nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + st := s.state(home) + switch class { + case cloudwire.MsgPrices: + var p cloudwire.Prices + if cloudwire.Unmarshal(env.B, &p) != nil { + return false + } + until := pricedUntil(p) + changed := st.pricedUntil != 0 && until > st.pricedUntil + if until > st.pricedUntil { + st.pricedUntil = until + } + return changed + default: + var lp cloudwire.LP + if cloudwire.Unmarshal(env.B, &lp) != nil { + return false + } + plugged := make(map[string]bool, len(lp.Loadpoints)) + changed := false + for _, l := range lp.Loadpoints { + plugged[l.ID] = l.Plugged + changed = changed || (st.plugged != nil && l.Plugged && !st.plugged[l.ID]) + } + st.plugged = plugged + return changed + } +} + +// pricedUntil is where the last priced slot ends. +func pricedUntil(p cloudwire.Prices) int64 { + last := -1 + for i := range max(len(p.SpotMinor), len(p.TotalMinor)) { + if i < len(p.SpotMinor) && p.SpotMinor[i] != nil || i < len(p.TotalMinor) && p.TotalMinor[i] != nil { + last = i + } + } + if last < 0 || p.SlotMs <= 0 { + return 0 + } + return p.T0Ms + int64(last+1)*p.SlotMs } // BoxConnected stops the dead man's switch for a home whose box is back. @@ -243,45 +356,45 @@ func (s *Service) unreachable(home string, gen uint64) { if err != nil { return } - s.enqueue(func(ctx context.Context) { s.send(ctx, home, payload, unreachableExpiry) }) + s.enqueue(func(ctx context.Context) { s.send(ctx, home, cloudwire.PushKindAlert, payload, unreachableExpiry) }) } func (s *Service) state(home string) *homeState { st := s.homes[home] if st == nil { - st = &homeState{} + st = &homeState{sent: map[string][]time.Time{}} s.homes[home] = st } return st } -// allow counts one alert against the home's hourly cap. -func (s *Service) allow(home string) bool { +// allow counts one push of a kind against the home's hourly cap. +func (s *Service) allow(home, kindName string) bool { s.mu.Lock() defer s.mu.Unlock() st := s.state(home) now := s.now() - keep := st.sent[:0] - for _, at := range st.sent { + var keep []time.Time + for _, at := range st.sent[kindName] { if now.Sub(at) < time.Hour { keep = append(keep, at) } } - st.sent = keep - if len(st.sent) >= MaxAlertsPerHour { + if len(keep) >= kinds[kindName].perHour { + st.sent[kindName] = keep return false } - st.sent = append(st.sent, now) + st.sent[kindName] = append(keep, now) return true } -// send posts one payload to every phone of the home registered for alerts, +// send posts one payload to every device of the home registered for a kind, // and forgets the tokens Apple says are gone. -func (s *Service) send(ctx context.Context, home string, payload []byte, expiry time.Duration) { +func (s *Service) send(ctx context.Context, home, kindName string, payload []byte, expiry time.Duration) { if s.sender == nil { return } - regs, err := s.reg.PushRegs(home, cloudwire.PushKindAlert) + regs, err := s.reg.PushRegs(home, kindName) if err != nil { s.log.Warn("could not read push registrations", "err", err) return @@ -294,15 +407,16 @@ func (s *Service) send(ctx context.Context, home string, payload []byte, expiry if len(regs) == 0 { return } - if !s.allow(home) { - s.log.Warn("a home passed its alert cap; alert dropped", "cap_per_hour", MaxAlertsPerHour) + k := kinds[kindName] + if !s.allow(home, kindName) { + s.log.Warn("a home passed its push cap; push dropped", "kind", kindName, "cap_per_hour", k.perHour) return } var sent, gone, failed int exp := s.now().Add(expiry) for _, r := range regs { res, err := s.sender.Send(ctx, apns.Notification{Environment: r.Environment, DeviceToken: r.Token, - Payload: payload, Expiration: exp}) + Type: k.pushType, Payload: payload, Expiration: exp}) switch { case err != nil: failed++ @@ -310,13 +424,13 @@ func (s *Service) send(ctx context.Context, home string, payload []byte, expiry sent++ case res.Gone: gone++ - if err := s.reg.PushGone(home, r.Token, res.GoneSince); err != nil { + if err := s.reg.PushGone(home, r.Token, kindName, res.GoneSince); err != nil { s.log.Warn("could not forget a gone token", "err", err) } default: failed++ - s.log.Warn("APNs refused an alert", "status", res.Status, "reason", res.Reason) + s.log.Warn("APNs refused a push", "kind", kindName, "status", res.Status, "reason", res.Reason) } } - s.log.Info("alert sent", "phones", sent, "gone", gone, "failed", failed) + s.log.Info("push sent", "kind", kindName, "devices", sent, "gone", gone, "failed", failed) } diff --git a/internal/push/widgets_test.go b/internal/push/widgets_test.go new file mode 100644 index 0000000..251f745 --- /dev/null +++ b/internal/push/widgets_test.go @@ -0,0 +1,279 @@ +package push + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/apns" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +const ( + day = int64(24 * 3600 * 1000) + hour = int64(3600 * 1000) +) + +// boxSigner signs the prices and lp a box sends, so the service reads them +// as the uplink hands them over. +type boxSigner struct { + t *testing.T + key *ecdsa.PrivateKey + seq uint64 +} + +func newBoxSigner(t *testing.T) *boxSigner { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return &boxSigner{t: t, key: key} +} + +func (b *boxSigner) envelope(home, class string, body any) []byte { + b.t.Helper() + env, err := cloudwire.Seal(b.key, home, class, body) + if err != nil { + b.t.Fatal(err) + } + raw, err := env.Encode() + if err != nil { + b.t.Fatal(err) + } + return raw +} + +func (b *boxSigner) header() cloudwire.Header { + b.seq++ + return cloudwire.Header{Seq: b.seq, Boot: bytes.Repeat([]byte{0xb0}, cloudwire.BootBytes)} +} + +// prices covers n hourly slots from t0, priced up to the slot priced. +func (b *boxSigner) prices(home string, t0 int64, n, priced int) []byte { + p := cloudwire.Prices{Header: b.header(), Zone: "SE3", Currency: "SEK", T0Ms: t0, SlotMs: hour, + SpotMinor: make([]*int64, n), TotalMinor: make([]*int64, n)} + for i := range priced { + v := int64(100 + i) + p.SpotMinor[i], p.TotalMinor[i] = &v, &v + } + return b.envelope(home, cloudwire.MsgPrices, p) +} + +func (b *boxSigner) lp(home string, plugged ...bool) []byte { + l := cloudwire.LP{Header: b.header(), Loadpoints: []cloudwire.Loadpoint{}, Feedback: []cloudwire.Feedback{}} + for i, p := range plugged { + l.Loadpoints = append(l.Loadpoints, cloudwire.Loadpoint{ID: string(rune('a' + i)), Src: "src", Plugged: p}) + } + return b.envelope(home, cloudwire.MsgLP, l) +} + +func (r *rig) registerWidgets(t *testing.T, dev cloudwire.Device, scopes []string, token string) error { + t.Helper() + return r.svc.Register(r.home, dev, scopes, cloudwire.PushRegister{ + APNsToken: []byte(token), Environment: cloudwire.PushSandbox, Kind: KindWidgets}) +} + +// taken returns what went to Apple since the last call. +func (r *rig) taken() []apns.Notification { + r.drain() + r.apple.mu.Lock() + defer r.apple.mu.Unlock() + out := r.apple.sent + r.apple.sent = nil + return out +} + +// A widget registers with ftw.live.read, as a viewer's may; alerts still need +// ftw.events.read. +func TestAWidgetRegistersWithLiveRead(t *testing.T) { + r := newRig(t) + if err := r.registerWidgets(t, r.viewer, r.viewer.Scopes, "viewer-widgets"); err != nil { + t.Fatalf("a viewer's widget: %v", err) + } + if err := r.registerWidgets(t, r.owner, []string{protocol.ScopeEventsRead}, "no-live"); err != ErrNotForThisDevice { + t.Fatalf("a device without live read registered a widget: %v", err) + } + regs, _ := r.reg.PushRegs(r.home, KindWidgets) + if len(regs) != 1 || regs[0].DeviceID != r.viewer.ID { + t.Fatalf("widgets %+v", regs) + } + if alerts, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(alerts) != 0 { + t.Fatal("a widget registration became an alert") + } +} + +// Prices that reach further than before, as when tomorrow's arrive, send one +// widgets push to the widgets and nothing to the alerts. The first prices +// after a start, a resend and the shift at midnight send nothing. +func TestTomorrowsPricesReloadTheWidgets(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.register(t, r.owner, r.all, "owner-alerts") + _ = r.registerWidgets(t, r.owner, r.all, "owner-widgets") + _ = r.registerWidgets(t, r.viewer, r.viewer.Scopes, "viewer-widgets") + today := int64(1_791_756_000_000) + + r.svc.Held(r.home, cloudwire.MsgPrices, box.prices(r.home, today, 48, 24)) + r.svc.Held(r.home, cloudwire.MsgPrices, box.prices(r.home, today, 48, 24)) + if got := r.taken(); len(got) != 0 { + t.Fatalf("today's prices sent %d", len(got)) + } + r.svc.Held(r.home, cloudwire.MsgPrices, box.prices(r.home, today, 48, 48)) + got := r.taken() + if len(got) != 2 { + t.Fatalf("tomorrow's prices went to %d devices", len(got)) + } + for _, n := range got { + if n.Type != apns.PushWidgets || string(n.Payload) != `{"aps":{"content-changed":true}}` || + !bytes.HasSuffix(n.DeviceToken, []byte("-widgets")) { + t.Fatalf("push %+v", n) + } + } + // Midnight: the day moves on, and the priced hours end where they did. + r.svc.Held(r.home, cloudwire.MsgPrices, box.prices(r.home, today+day, 24, 24)) + if got := r.taken(); len(got) != 0 { + t.Fatalf("midnight sent %d", len(got)) + } +} + +// A car plugged in sends one widgets push. Staying plugged, unplugging and +// the first lp after a start send nothing. +func TestACarPluggedInReloadsTheWidgets(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.registerWidgets(t, r.owner, r.all, "owner-widgets") + for i, step := range []struct { + plugged []bool + pushes int + }{ + {[]bool{true, false}, 0}, // the first lp only sets what the next is compared with + {[]bool{true, false}, 0}, + {[]bool{true, true}, 1}, + {[]bool{false, true}, 0}, + {[]bool{true, true}, 1}, + } { + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, step.plugged...)) + if got := r.taken(); len(got) != step.pushes { + t.Fatalf("step %d sent %d", i, len(got)) + } + } +} + +// A car that flaps between plugged and not cannot spend Apple's budget for +// the home's widgets. +func TestWidgetPushesAreCappedPerHome(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.registerWidgets(t, r.owner, r.all, "owner-widgets") + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + sent := 0 + for range MaxWidgetPushesPerHour + 3 { + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + sent += len(r.taken()) + } + if sent != MaxWidgetPushesPerHour { + t.Fatalf("sent %d in an hour", sent) + } + r.clock.advance(time.Hour) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + if got := r.taken(); len(got) != 1 { + t.Fatalf("the cap did not lift after an hour: %d", len(got)) + } + // The alert cap is its own. + _ = r.register(t, r.owner, r.all, "owner-alerts") + r.svc.Event(r.home, []byte{0xa0}) + if got := r.taken(); len(got) != 1 || got[0].Type != apns.PushAlert { + t.Fatalf("an alert after the widgets: %+v", got) + } +} + +// A roster prunes each kind by its own scope: a device that loses events +// keeps its widgets, and one that loses live loses them. +func TestARosterPrunesEachKindByItsScope(t *testing.T) { + r := newRig(t) + _ = r.register(t, r.owner, r.all, "alerts") + _ = r.registerWidgets(t, r.owner, r.all, "widgets") + grant := cloudwire.Grant{ID: bytes.Repeat([]byte{4}, 16), Scopes: r.all} + count := func(kind string) int { + regs, _ := r.reg.PushRegs(r.home, kind) + return len(regs) + } + noEvents := r.owner + noEvents.Scopes = []string{protocol.ScopeLiveRead} + r.svc.Roster(r.home, &cloudwire.Roster{Devices: []cloudwire.Device{noEvents}, Grant: grant}) + if count(cloudwire.PushKindAlert) != 0 || count(KindWidgets) != 1 { + t.Fatal("losing events did not drop alerts alone") + } + grant.Scopes = []string{protocol.ScopeEventsRead} + r.svc.Roster(r.home, &cloudwire.Roster{Devices: []cloudwire.Device{r.owner}, Grant: grant}) + if count(KindWidgets) != 0 { + t.Fatal("a grant without live kept the widgets") + } +} + +// A suspended device's widgets get nothing, as its alerts get nothing. +func TestASuspendedDevicesWidgetsGetNothing(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.registerWidgets(t, r.owner, r.all, "owner-widgets") + _ = r.registerWidgets(t, r.viewer, r.viewer.Scopes, "viewer-widgets") + _ = r.reg.Suspend(r.home, r.viewer.ID, r.viewer.Epoch, r.clock.time()) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + if got := r.taken(); len(got) != 1 || string(got[0].DeviceToken) != "owner-widgets" { + t.Fatalf("sent to %+v", got) + } +} + +// A device holds a few tokens of each kind; past that the oldest goes, so a +// device that registers without end cannot fill the table or make one push +// call Apple without end. +func TestADeviceHoldsAFewTokensPerKind(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + for i := range 50 { + r.clock.advance(time.Millisecond) + if err := r.registerWidgets(t, r.viewer, r.viewer.Scopes, "viewer-"+string(rune('A'+i))); err != nil { + t.Fatal(err) + } + } + _ = r.register(t, r.owner, r.all, "owner-alerts") + regs, _ := r.reg.PushRegs(r.home, KindWidgets) + if len(regs) != MaxTokensPerDevice { + t.Fatalf("the viewer holds %d widget tokens", len(regs)) + } + for _, reg := range regs { + if reg.Token[len(reg.Token)-1] < byte('A'+50-MaxTokensPerDevice) { + t.Fatalf("an old token stayed: %s", reg.Token) + } + } + if alerts, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(alerts) != 1 { + t.Fatal("one device's widget tokens pushed out another kind") + } + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + if got := r.taken(); len(got) != MaxTokensPerDevice { + t.Fatalf("one plug-in called Apple %d times", len(got)) + } +} + +// Apple refusing one kind of push forgets that kind's registration of the +// token, and the token's other kinds stay. +func TestAGoneTokenGoesOnlyForItsKind(t *testing.T) { + r := newRig(t) + box := newBoxSigner(t) + _ = r.register(t, r.owner, r.all, "same-token") + _ = r.registerWidgets(t, r.owner, r.all, "same-token") + r.apple.gone["same-token"] = true + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, false)) + r.svc.Held(r.home, cloudwire.MsgLP, box.lp(r.home, true)) + r.taken() + if widgets, _ := r.reg.PushRegs(r.home, KindWidgets); len(widgets) != 0 { + t.Fatal("a widget token Apple refused stayed") + } + if alerts, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(alerts) != 1 { + t.Fatal("a widgets refusal took the alert registration with it") + } +} diff --git a/internal/registry/push.go b/internal/registry/push.go index 1bc25ec..71c93d7 100644 --- a/internal/registry/push.go +++ b/internal/registry/push.go @@ -79,43 +79,55 @@ func (r *Registry) PushRegs(home, kind string) ([]PushReg, error) { return out, rows.Err() } -// PushGone forgets a token Apple says is gone, unless the phone registered -// it again after Apple last knew it valid. -func (r *Registry) PushGone(home string, token []byte, since time.Time) error { +// PushGone forgets one kind's registration of a token Apple says is gone, +// unless the phone registered it again after Apple last knew it valid. The +// token's registrations for other kinds go to other topics and stay. +func (r *Registry) PushGone(home string, token []byte, kind string, since time.Time) error { if since.IsZero() { - _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND token = ?`, home, token) + _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND token = ? AND kind = ?`, home, token, kind) return err } - _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND token = ? AND registered_ms <= ?`, - home, token, since.UnixMilli()) + _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND token = ? AND kind = ? AND registered_ms <= ?`, + home, token, kind, since.UnixMilli()) return err } -// PushPrune keeps only the registrations whose device keep allows at its -// epoch, as a new roster says. -func (r *Registry) PushPrune(home string, keep func(deviceID string, epoch uint64) bool) error { - rows, err := r.db.Query(`SELECT DISTINCT device_id, epoch FROM push WHERE home = ?`, home) +// PushTrim keeps a device's newest keep registrations of one kind and +// forgets the rest. +func (r *Registry) PushTrim(home, deviceID, kind string, keep int) error { + _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND device_id = ? AND kind = ? AND rowid NOT IN ( + SELECT rowid FROM push WHERE home = ? AND device_id = ? AND kind = ? ORDER BY registered_ms DESC, rowid DESC LIMIT ?)`, + home, deviceID, kind, home, deviceID, kind, keep) + return err +} + +// PushPrune keeps only the registrations keep allows: a device at its epoch, +// for one kind, as a new roster says. +func (r *Registry) PushPrune(home string, keep func(deviceID string, epoch uint64, kind string) bool) error { + rows, err := r.db.Query(`SELECT DISTINCT device_id, epoch, kind FROM push WHERE home = ?`, home) if err != nil { return err } type entry struct { id string epoch int64 + kind string } var gone []entry for rows.Next() { var e entry - if err := rows.Scan(&e.id, &e.epoch); err != nil { + if err := rows.Scan(&e.id, &e.epoch, &e.kind); err != nil { _ = rows.Close() return err } - if !keep(e.id, uint64(e.epoch)) { + if !keep(e.id, uint64(e.epoch), e.kind) { gone = append(gone, e) } } _ = rows.Close() for _, e := range gone { - if _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND device_id = ? AND epoch = ?`, home, e.id, e.epoch); err != nil { + if _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND device_id = ? AND epoch = ? AND kind = ?`, + home, e.id, e.epoch, e.kind); err != nil { return err } } diff --git a/internal/registry/push_test.go b/internal/registry/push_test.go index d2931d7..2d3a12c 100644 --- a/internal/registry/push_test.go +++ b/internal/registry/push_test.go @@ -27,7 +27,7 @@ func TestARegistrationBelongsToARosterEntryAndGoesWithIt(t *testing.T) { } // The roster drops phone-b and moves phone-a's epoch: both go. - if err := r.PushPrune("h1", func(id string, epoch uint64) bool { return id == "phone-a" && epoch == 2 }); err != nil { + if err := r.PushPrune("h1", func(id string, epoch uint64, _ string) bool { return id == "phone-a" && epoch == 2 }); err != nil { t.Fatal(err) } if got, _ := r.PushRegs("h1", "alert"); len(got) != 0 { @@ -62,13 +62,13 @@ func TestAGoneTokenGoesUnlessRegisteredSince(t *testing.T) { now := time.Unix(1_791_820_800, 0) _ = r.Claim("h1", []byte("pk"), now) _ = r.PushRegister(reg("h1", "phone-a", 1, "tok", now)) - if err := r.PushGone("h1", []byte("tok"), now.Add(-time.Hour)); err != nil { + if err := r.PushGone("h1", []byte("tok"), "alert", now.Add(-time.Hour)); err != nil { t.Fatal(err) } if got, _ := r.PushRegs("h1", "alert"); len(got) != 1 { t.Fatal("a token registered after Apple last knew it was dropped") } - _ = r.PushGone("h1", []byte("tok"), now.Add(time.Hour)) + _ = r.PushGone("h1", []byte("tok"), "alert", now.Add(time.Hour)) if got, _ := r.PushRegs("h1", "alert"); len(got) != 0 { t.Fatal("a gone token was kept") } diff --git a/internal/uplink/hooks_test.go b/internal/uplink/hooks_test.go index 8ffe84d..12c07c1 100644 --- a/internal/uplink/hooks_test.go +++ b/internal/uplink/hooks_test.go @@ -14,6 +14,7 @@ import ( type heard struct { mu sync.Mutex events [][]byte + classes []string rosters []uint64 gone []string conns int @@ -22,10 +23,13 @@ type heard struct { func listen(s *service) *heard { h := &heard{} s.up.OnConnect = func(string) { h.mu.Lock(); h.conns++; h.mu.Unlock() } - s.up.OnEvent = func(home string, env []byte) { + s.up.OnHeld = func(home, class string, env []byte) { h.mu.Lock() - h.events = append(h.events, env) - h.mu.Unlock() + defer h.mu.Unlock() + h.classes = append(h.classes, class) + if class == cloudwire.MsgEvent { + h.events = append(h.events, env) + } } s.up.OnRoster = func(home string, r *cloudwire.Roster) { h.mu.Lock() @@ -68,6 +72,25 @@ func TestAnEventReachesPushUnchanged(t *testing.T) { } } +// Every envelope the service holds reaches push with its class, so prices +// and a car plugged in can reload widgets. A roster is not held this way. +func TestEveryHeldEnvelopeReachesPushWithItsClass(t *testing.T) { + s := newService(t) + h := listen(s) + b := newBox(t) + _, _ = b.connect(s, nil) + b.send(cloudwire.MsgRoster, b.roster(protocol.ScopeLiveRead)) + b.send(cloudwire.MsgPrices, cloudwire.Prices{Header: b.header(), SpotMinor: []*int64{}, TotalMinor: []*int64{}}) + b.send(cloudwire.MsgLP, cloudwire.LP{Header: b.header(), Loadpoints: []cloudwire.Loadpoint{}, Feedback: []cloudwire.Feedback{}}) + classes := h.count(func(h *heard) int { return len(h.classes) }) + wait(t, "two held envelopes", func() bool { return classes() == 2 }) + h.mu.Lock() + defer h.mu.Unlock() + if h.classes[0] != cloudwire.MsgPrices || h.classes[1] != cloudwire.MsgLP { + t.Fatalf("push heard %v", h.classes) + } +} + // The end of an uplink tells push why: the box's bye, or nothing when the // socket dropped. A connection a newer one replaced says nothing. func TestTheEndOfAnUplinkSaysWhy(t *testing.T) { diff --git a/internal/uplink/uplink.go b/internal/uplink/uplink.go index 870d7f7..49bb46f 100644 --- a/internal/uplink/uplink.go +++ b/internal/uplink/uplink.go @@ -67,12 +67,12 @@ type Uplink struct { // box that just came back for one-second updates and push can stop // waiting to say it is out of reach. Set before serving. OnConnect func(home string) - // OnEvent gets each event the box signed, as the envelope it arrived - // in; OnRoster each roster the service keeps; OnGone the end of the - // home's current uplink, with the reason the box gave in its bye, or - // "" when the socket just dropped. Push uses them. Each runs on the - // box's goroutine and must not block. Set before serving. - OnEvent func(home string, envelope []byte) + // OnHeld gets each envelope the service holds, with its class, as the + // box signed it; OnRoster each roster the service keeps; OnGone the end + // of the home's current uplink, with the reason the box gave in its + // bye, or "" when the socket just dropped. Push uses them. Each runs on + // the box's goroutine and must not block. Set before serving. + OnHeld func(home, class string, envelope []byte) OnRoster func(home string, r *cloudwire.Roster) OnGone func(home, why string) } @@ -369,8 +369,8 @@ func (c *boxConn) apply(env cloudwire.Envelope) (stop bool, err error) { if err := c.handle.Put(env.T, raw, h.Seq, env.T == cloudwire.MsgLive && carriesTable(env.B)); err != nil { return true, err } - if env.T == cloudwire.MsgEvent && c.u.OnEvent != nil { - c.u.OnEvent(c.home, raw) + if c.u.OnHeld != nil { + c.u.OnHeld(c.home, env.T, raw) } return false, nil }