Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions deploy/apns.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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_<KEYID>.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)

Expand Down
51 changes: 42 additions & 9 deletions internal/apns/apns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions internal/apns/apns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading
Loading