Tell a member who is not there that somebody is waiting
The admission queue has only ever existed in the hub's memory, and only ever reached members over their own streams. A member who was not in the room could not be told at all — not because nobody had written the notification, but because there was nowhere for one to come from. The app registers its device at join, POST /native/devices, behind the same two locks as the other native write: the JSON content type an HTML form cannot send, and the Origin csrf.Protect reads. Tokens are hex and checked to be; anything else is refused before it is stored, and a token is never logged — it is an address for a person's device. The hub calls back when someone begins waiting, on a goroutine of its own and never under the lock, because the app's side reads the database and talks to Apple while the hub's mutex is held across every peer in every room. It passes the members who were admitted *at that moment*, gathered under the lock it already holds: those are the people with the queue on screen, and pushing at them would be a second telling of something they can see. Asking again on the other side would race the room. internal/calls/apns is small on purpose — no badges, no collapse identifiers, no background pushes. The one thing this app has ever needed to say to a closed app is that somebody is at the door. It is cgo-free like the rest of the server: ES256 over crypto/ecdsa, and HTTP/2 from net/http over TLS without help. Two things there are worth more than the rest. The JWS signature is the raw r||s pair, each padded to the curve size, and not the ASN.1 form SignASN1 gives — Apple refuses that with "InvalidProviderToken" and no further hint, so the test verifies the token against the public key that made it rather than merely producing one. And Apple's refusals are sorted into keep-this-token and delete-it: Unregistered, BadDeviceToken and DeviceTokenNotForTopic are dead devices, while an unreachable Apple is always Retry. An outage read as "the device is gone" would empty the table in an afternoon. No key configured is not a degraded mode and logs nothing. Knocks still reach everyone in the room exactly as before; it is only the member elsewhere who goes untold, which is where this app already was. A *partly* configured key refuses startup, because half a push key is a mistake worth catching at boot rather than in a goroutine on the first knock. Who is told is the whole design question, and this answers it as: every member with a registered device who was not in the room. Members, because a knock is theirs to answer. Not the creator alone, because a meeting someone else started is still the team's to let people into. Gates: GOTOOLCHAIN=go1.26.5 make ci, make race (the hub changed), make native-test, make native-browser, make browser, swift test (24), and both app builds. Nine tests on the APNs client against a fake Apple, seven on registration and the who-is-told decision, three on the client half. Not covered: a real push. That needs the entitlement, a signed build and a token Apple will actually issue, none of which exist here yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
22 files changed,
+1312
−10
README.md+13 −2callscore/meetings.go+49 −0callscore/meetings_test.go+48 −0internal/calls/apns/apns.go+230 −0internal/calls/apns/apns_test.go+276 −0internal/calls/app.go+23 −1internal/calls/config.go+74 −0internal/calls/hub/admission.go+13 −0internal/calls/hub/hub.go+11 −0internal/calls/knockpush.go+132 −0internal/calls/knockpush_test.go+206 −0internal/calls/migrations/0005_create_device_tokens.sql+6 −0internal/calls/migrations/schema.sql+6 −0internal/calls/models.go+30 −0internal/calls/store.go+51 −0native/README.md+14 −7native/apple/Sources/CallsKit/Call.swift+4 −0native/apple/Sources/CallsKit/CallsModel.swift+21 −0native/apple/Sources/CallsKit/RemoteKnocks.swift+51 −0native/apple/Sources/CallsMac/CallsMac.swift+20 −0native/bridge/bridge.go+12 −0native/ios/Sources/CallsPhone.swift+22 −0
CI failed — run details
diff --git a/README.md b/README.md| index 5bd3d35..8982b36 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -135,8 +135,19 @@ default rather than a refusal: `CALLS_ZONE` (an IANA zone, defaulting |
| to UTC when unset) and `CARLOS_RELAY` (empty means no relay, §5.5 — |
| only a *malformed* value refuses startup), delivered by `carlos relay |
| enable --app calls` rather than set by hand. Secrets — |
| -`CALLS_INSTANCE_KEY`, `CALLS_DEK` and `CALLS_SMTP_PASS` — go through |
| -`carlos secrets`; the rest go through `carlos env`. Mail reads |
| +`CALLS_INSTANCE_KEY`, `CALLS_DEK`, `CALLS_SMTP_PASS` and |
| +`CALLS_APNS_KEY` — go through `carlos secrets`; the rest go through |
| +`carlos env`. |
| + |
| +Apple push is a sixth group, and all of it or none of it: |
| +`CALLS_APNS_KEY` (the `.p8` as Apple issues it, PEM and multi-line), |
| +`CALLS_APNS_KEY_ID`, `CALLS_APNS_TEAM_ID`, `CALLS_APNS_TOPIC_IOS` and |
| +`CALLS_APNS_TOPIC_MACOS`, with `CALLS_APNS_SANDBOX` for a development |
| +build's tokens. Unset, the server sends no push and says so nowhere — |
| +a knock still reaches every member in the room over their own stream, |
| +which is where this app was before push existed. Partly set, startup |
| +refuses: half a push key is a mistake to catch at boot rather than in a |
| +goroutine on the first knock. Mail reads |
| `CALLS_SMTP_*` with `CARLOS_SMTP_*` as a per-variable fallback; the |
| platform supplies a sender too |
| (`CARLOS_SMTP_FROM`), which `CALLS_SMTP_FROM` can optionally override, |
diff --git a/callscore/meetings.go b/callscore/meetings.go| index bac90e6..bfc537c 100644 |
| --- a/callscore/meetings.go |
| +++ b/callscore/meetings.go |
| @@ -73,3 +73,52 @@ func StartMeeting(ctx context.Context, origin string, client *http.Client) (stri |
| } |
| return out.Sqid, nil |
| } |
| + |
| +// RegisterDevice tells the server where this device can be reached when |
| +// the app is not running. |
| +// |
| +// Best effort by design, and the caller treats a failure as nothing |
| +// happening: a device that could not register is a device that will not |
| +// be told about a knock, which is exactly where every device was before |
| +// push existed. It is never a reason to fail a join. |
| +func RegisterDevice(ctx context.Context, origin string, client *http.Client, token, platform string) error { |
| + trimmed, err := checkOrigin(origin) |
| + if err != nil { |
| + return err |
| + } |
| + safe, err := safeClient(client) |
| + if err != nil { |
| + return err |
| + } |
| + if platform != "ios" && platform != "macos" { |
| + return errors.New("unknown device platform") |
| + } |
| + body, err := json.Marshal(map[string]string{"token": token, "platform": platform}) |
| + if err != nil { |
| + return errors.New("cannot build Calls request") |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| + defer cancel() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/devices", bytes.NewReader(body)) |
| + if err != nil { |
| + return errors.New("cannot build Calls request") |
| + } |
| + req.Header.Set("Accept", "application/json") |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Origin", trimmed) |
| + res, err := safe.Do(req) |
| + if err != nil { |
| + return errors.New("cannot reach Calls") |
| + } |
| + defer res.Body.Close() |
| + switch { |
| + case res.StatusCode == http.StatusNoContent: |
| + return nil |
| + case res.StatusCode == http.StatusNotFound: |
| + // An older server without the endpoint. Not an error worth |
| + // showing anyone: it simply has no push. |
| + return errors.New("this Calls server does not take device registrations") |
| + default: |
| + return errors.New("Calls refused this device registration") |
| + } |
| +} |
diff --git a/callscore/meetings_test.go b/callscore/meetings_test.go| index d96b42c..da67001 100644 |
| --- a/callscore/meetings_test.go |
| +++ b/callscore/meetings_test.go |
| @@ -111,3 +111,51 @@ func TestStartMeetingNamesTheRefusalItCanDoSomethingAbout(t *testing.T) { |
| server.Close() |
| } |
| } |
| + |
| +func TestRegisterDeviceSendsTheTokenAndBothLocks(t *testing.T) { |
| + var body, origin, contentType atomic.Value |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + origin.Store(r.Header.Get("Origin")) |
| + contentType.Store(r.Header.Get("Content-Type")) |
| + var in map[string]string |
| + json.NewDecoder(r.Body).Decode(&in) |
| + body.Store(in) |
| + w.WriteHeader(http.StatusNoContent) |
| + })) |
| + defer server.Close() |
| + if err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabbcc", "macos"); err != nil { |
| + t.Fatal(err) |
| + } |
| + in := body.Load().(map[string]string) |
| + if in["token"] != "aabbcc" || in["platform"] != "macos" { |
| + t.Fatal("wrong body", in) |
| + } |
| + if origin.Load() != server.URL || contentType.Load() != "application/json" { |
| + t.Fatal("missing a lock", origin.Load(), contentType.Load()) |
| + } |
| +} |
| + |
| +func TestRegisterDeviceRefusesAnUnknownPlatformWithoutCallingTheServer(t *testing.T) { |
| + var calls atomic.Int32 |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) |
| + defer server.Close() |
| + if err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabb", "android"); err == nil { |
| + t.Fatal("accepted an unknown platform") |
| + } |
| + if calls.Load() != 0 { |
| + t.Fatal("called the server anyway") |
| + } |
| +} |
| + |
| +func TestRegisterDeviceOnAnOlderServerIsAPlainRefusal(t *testing.T) { |
| + // A server without the endpoint has no push. That is a fact about |
| + // the server, not a failure worth showing anyone. |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer server.Close() |
| + err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabb", "ios") |
| + if err == nil || !strings.Contains(err.Error(), "does not take device registrations") { |
| + t.Fatal("unhelpful error", err) |
| + } |
| +} |
diff --git a/internal/calls/apns/apns.go b/internal/calls/apns/apns.go| new file mode 100644 |
| index 0000000..7e54ca8 |
| --- /dev/null |
| +++ b/internal/calls/apns/apns.go |
| @@ -0,0 +1,230 @@ |
| +// Package apns sends one kind of notification to Apple: someone is |
| +// waiting to be let into a meeting. It is deliberately small — no |
| +// badges, no collapse identifiers, no background pushes — because the |
| +// only thing this app has ever needed to say to a closed app is that |
| +// somebody is at the door. |
| +// |
| +// It is cgo-free, like the rest of the server. The provider token is |
| +// ES256 over the stdlib's crypto/ecdsa, and HTTP/2 comes from net/http |
| +// over TLS without help, which is the whole of what APNs requires. |
| +package apns |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/json" |
| + "encoding/pem" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// Hosts Apple serves. Production tokens are refused by the sandbox and |
| +// the other way round, with an error that names neither, so the choice |
| +// is configuration rather than something to guess at. |
| +const ( |
| + HostProduction = "https://api.push.apple.com" |
| + HostSandbox = "https://api.sandbox.push.apple.com" |
| +) |
| + |
| +// providerTokenLife is how long one JWT is used for. Apple refuses a |
| +// token older than an hour and refuses a client that mints them more |
| +// often than every twenty minutes, so anything in between is correct and |
| +// fifty minutes leaves room for a slow clock on either side. |
| +const providerTokenLife = 50 * time.Minute |
| + |
| +// maxBody caps what is read from an error response. Apple's are a few |
| +// dozen bytes of JSON; anything larger is not Apple. |
| +const maxBody = 8 * 1024 |
| + |
| +type Config struct { |
| + KeyID string |
| + TeamID string |
| + Key *ecdsa.PrivateKey |
| + // Topic per platform — the bundle identifier, which differs between |
| + // the Mac and iPhone apps, so a token is useless without knowing |
| + // which app issued it. |
| + Topics map[string]string |
| + Host string |
| + // Now and HTTP exist for the tests, which run a fake Apple. |
| + Now func() time.Time |
| + HTTP *http.Client |
| +} |
| + |
| +type Client struct { |
| + cfg Config |
| + mu sync.Mutex |
| + jwt string |
| + made time.Time |
| +} |
| + |
| +// Verdict is what the caller must do about a token, which is the only |
| +// part of an APNs response worth carrying upwards. |
| +type Verdict int |
| + |
| +const ( |
| + // Delivered: Apple accepted it. It says nothing about the device. |
| + Delivered Verdict = iota |
| + // Gone: this token is dead and must be deleted. The app was removed, |
| + // or the token was reissued to someone else. Keeping it means |
| + // pushing at nobody for ever. |
| + Gone |
| + // Retry: transient, or ours to fix. The token stays. |
| + Retry |
| +) |
| + |
| +func ParseKey(data []byte) (*ecdsa.PrivateKey, error) { |
| + block, _ := pem.Decode(data) |
| + if block == nil { |
| + return nil, errors.New("apns: key is not PEM; expected the .p8 Apple issued") |
| + } |
| + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) |
| + if err != nil { |
| + return nil, errors.New("apns: key is not a PKCS#8 private key") |
| + } |
| + key, ok := parsed.(*ecdsa.PrivateKey) |
| + if !ok { |
| + return nil, errors.New("apns: key is not an ECDSA key; APNs keys are P-256") |
| + } |
| + return key, nil |
| +} |
| + |
| +func New(cfg Config) (*Client, error) { |
| + if cfg.KeyID == "" || cfg.TeamID == "" || cfg.Key == nil { |
| + return nil, errors.New("apns: key id, team id and key are all required") |
| + } |
| + if len(cfg.Topics) == 0 { |
| + return nil, errors.New("apns: at least one platform topic is required") |
| + } |
| + if cfg.Host == "" { |
| + cfg.Host = HostProduction |
| + } |
| + if cfg.Now == nil { |
| + cfg.Now = time.Now |
| + } |
| + if cfg.HTTP == nil { |
| + // No Timeout here: the deadline rides on the request context, so |
| + // one slow push cannot outlive the caller that asked for it. |
| + cfg.HTTP = &http.Client{} |
| + } |
| + return &Client{cfg: cfg}, nil |
| +} |
| + |
| +// providerToken mints or reuses the ES256 JWT. Held under the mutex for |
| +// the whole mint: two goroutines racing here would each make one, and |
| +// Apple counts that against a rate limit measured in tokens per |
| +// connection rather than tokens per need. |
| +func (c *Client) providerToken() (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + now := c.cfg.Now() |
| + if c.jwt != "" && now.Sub(c.made) < providerTokenLife { |
| + return c.jwt, nil |
| + } |
| + header := encodeSegment(map[string]any{"alg": "ES256", "kid": c.cfg.KeyID, "typ": "JWT"}) |
| + claims := encodeSegment(map[string]any{"iss": c.cfg.TeamID, "iat": now.Unix()}) |
| + signing := header + "." + claims |
| + digest := sha256.Sum256([]byte(signing)) |
| + r, s, err := ecdsa.Sign(rand.Reader, c.cfg.Key, digest[:]) |
| + if err != nil { |
| + return "", errors.New("apns: cannot sign the provider token") |
| + } |
| + // JWS wants the raw r||s pair, each left-padded to the curve size — |
| + // not the ASN.1 form ecdsa.SignASN1 would give, which Apple rejects |
| + // with a message about the token being malformed and no more. |
| + size := (c.cfg.Key.Curve.Params().BitSize + 7) / 8 |
| + signature := make([]byte, 2*size) |
| + r.FillBytes(signature[:size]) |
| + s.FillBytes(signature[size:]) |
| + c.jwt = signing + "." + base64.RawURLEncoding.EncodeToString(signature) |
| + c.made = now |
| + return c.jwt, nil |
| +} |
| + |
| +func encodeSegment(value map[string]any) string { |
| + b, _ := json.Marshal(value) |
| + return base64.RawURLEncoding.EncodeToString(b) |
| +} |
| + |
| +// Push tells one device that someone is waiting. `name` is the waiting |
| +// person's own text, from the server's roster, so it is carried as a |
| +// JSON value and never built into one. |
| +func (c *Client) Push(ctx context.Context, token, platform, name string) (Verdict, error) { |
| + topic, ok := c.cfg.Topics[platform] |
| + if !ok { |
| + return Retry, fmt.Errorf("apns: no topic configured for platform %q", platform) |
| + } |
| + jwt, err := c.providerToken() |
| + if err != nil { |
| + return Retry, err |
| + } |
| + body, err := json.Marshal(map[string]any{ |
| + "aps": map[string]any{ |
| + "alert": map[string]any{ |
| + "title": "Someone is waiting to join", |
| + "body": name, |
| + }, |
| + "sound": "default", |
| + }, |
| + }) |
| + if err != nil { |
| + return Retry, errors.New("apns: cannot build the payload") |
| + } |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Host+"/3/device/"+token, bytes.NewReader(body)) |
| + if err != nil { |
| + return Retry, errors.New("apns: cannot build the request") |
| + } |
| + req.Header.Set("authorization", "bearer "+jwt) |
| + req.Header.Set("apns-topic", topic) |
| + req.Header.Set("apns-push-type", "alert") |
| + // 10 is "deliver now", which is the only priority that makes sense |
| + // for a person standing at a door. |
| + req.Header.Set("apns-priority", "10") |
| + req.Header.Set("content-type", "application/json") |
| + res, err := c.cfg.HTTP.Do(req) |
| + if err != nil { |
| + return Retry, errors.New("apns: cannot reach Apple") |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode == http.StatusOK { |
| + return Delivered, nil |
| + } |
| + var out struct { |
| + Reason string `json:"reason"` |
| + } |
| + raw, _ := io.ReadAll(io.LimitReader(res.Body, maxBody)) |
| + _ = json.Unmarshal(raw, &out) |
| + return classify(res.StatusCode, out.Reason) |
| +} |
| + |
| +// classify turns a refusal into the one decision the caller has to make: |
| +// keep this token or delete it. Pure, so the table of Apple's reasons is |
| +// a thing a test can read rather than a thing a fake server has to act |
| +// out one status at a time. |
| +func classify(status int, reason string) (Verdict, error) { |
| + switch reason { |
| + case "BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic": |
| + // The token is wrong for this app or belongs to an app that is |
| + // gone. No amount of retrying changes that. |
| + return Gone, fmt.Errorf("apns: token rejected (%s)", reason) |
| + case "ExpiredProviderToken", "InvalidProviderToken", "MissingProviderToken": |
| + // Ours, not the device's. The token stays; the next push mints a |
| + // new JWT. |
| + return Retry, fmt.Errorf("apns: provider token refused (%s)", reason) |
| + } |
| + if status == http.StatusGone { |
| + return Gone, errors.New("apns: token is no longer registered") |
| + } |
| + if reason == "" { |
| + return Retry, fmt.Errorf("apns: refused with status %d", status) |
| + } |
| + return Retry, fmt.Errorf("apns: refused (%s)", reason) |
| +} |
diff --git a/internal/calls/apns/apns_test.go b/internal/calls/apns/apns_test.go| new file mode 100644 |
| index 0000000..9094156 |
| --- /dev/null |
| +++ b/internal/calls/apns/apns_test.go |
| @@ -0,0 +1,276 @@ |
| +package apns |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/json" |
| + "encoding/pem" |
| + "math/big" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func testKey(t *testing.T) (*ecdsa.PrivateKey, []byte) { |
| + t.Helper() |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + der, err := x509.MarshalPKCS8PrivateKey(key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return key, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) |
| +} |
| + |
| +func client(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { |
| + t.Helper() |
| + key, _ := testKey(t) |
| + server := httptest.NewServer(handler) |
| + t.Cleanup(server.Close) |
| + c, err := New(Config{ |
| + KeyID: "ABC123DEFG", TeamID: "TEAM123456", Key: key, |
| + Topics: map[string]string{"ios": "works.oficina.calls", "macos": "works.oficina.calls.mac"}, |
| + Host: server.URL, HTTP: server.Client(), |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return c, server |
| +} |
| + |
| +func TestParseKeyAcceptsAP8AndRefusesAnythingElse(t *testing.T) { |
| + _, pemBytes := testKey(t) |
| + if _, err := ParseKey(pemBytes); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, input := range map[string]string{ |
| + "not pem": "hello", |
| + "empty": "", |
| + "pem but not a key": "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n", |
| + } { |
| + if _, err := ParseKey([]byte(input)); err == nil { |
| + t.Errorf("accepted %s", name) |
| + } |
| + } |
| +} |
| + |
| +// The provider token is the part Apple rejects most opaquely — a wrong |
| +// signature encoding comes back as "InvalidProviderToken" and nothing |
| +// else — so it is verified here against the public key rather than |
| +// merely produced. |
| +func TestTheProviderTokenIsAValidES256JWTApplesWay(t *testing.T) { |
| + var seen atomic.Value |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { |
| + seen.Store(r.Header.Get("authorization")) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil { |
| + t.Fatal(err) |
| + } |
| + header, ok := seen.Load().(string) |
| + if !ok || !strings.HasPrefix(header, "bearer ") { |
| + t.Fatal("no bearer token", header) |
| + } |
| + parts := strings.Split(strings.TrimPrefix(header, "bearer "), ".") |
| + if len(parts) != 3 { |
| + t.Fatal("not three JWT segments") |
| + } |
| + var head map[string]any |
| + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) |
| + if err != nil || json.Unmarshal(raw, &head) != nil { |
| + t.Fatal("header is not base64url JSON") |
| + } |
| + if head["alg"] != "ES256" || head["kid"] != "ABC123DEFG" { |
| + t.Fatal("wrong header", head) |
| + } |
| + var claims map[string]any |
| + raw, err = base64.RawURLEncoding.DecodeString(parts[1]) |
| + if err != nil || json.Unmarshal(raw, &claims) != nil { |
| + t.Fatal("claims are not base64url JSON") |
| + } |
| + if claims["iss"] != "TEAM123456" { |
| + t.Fatal("wrong issuer", claims) |
| + } |
| + // The signature must be the raw r||s pair, each padded to 32 bytes. |
| + // ASN.1 — what SignASN1 produces — is the mistake this asserts against. |
| + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| + if err != nil || len(signature) != 64 { |
| + t.Fatalf("signature is %d bytes, want 64 raw r||s", len(signature)) |
| + } |
| + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) |
| + r := new(big.Int).SetBytes(signature[:32]) |
| + s := new(big.Int).SetBytes(signature[32:]) |
| + if !ecdsa.Verify(&c.cfg.Key.PublicKey, digest[:], r, s) { |
| + t.Fatal("signature does not verify against the key that made it") |
| + } |
| +} |
| + |
| +func TestOneTokenIsReusedAndThenReminted(t *testing.T) { |
| + var tokens sync.Map |
| + var count atomic.Int32 |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { |
| + tokens.Store(r.Header.Get("authorization"), true) |
| + count.Add(1) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + now := time.Now() |
| + c.cfg.Now = func() time.Time { return now } |
| + for range 3 { |
| + if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + distinct := 0 |
| + tokens.Range(func(_, _ any) bool { distinct++; return true }) |
| + if distinct != 1 { |
| + t.Fatalf("minted %d tokens for three pushes; Apple rate-limits that", distinct) |
| + } |
| + // Past the life, a new one. Apple refuses a token older than an hour. |
| + now = now.Add(providerTokenLife + time.Minute) |
| + if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil { |
| + t.Fatal(err) |
| + } |
| + distinct = 0 |
| + tokens.Range(func(_, _ any) bool { distinct++; return true }) |
| + if distinct != 2 { |
| + t.Fatal("an expired provider token was reused") |
| + } |
| +} |
| + |
| +func TestTheRequestCarriesWhatApplesRequires(t *testing.T) { |
| + var path, topic, pushType, priority atomic.Value |
| + var payload atomic.Value |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { |
| + path.Store(r.URL.Path) |
| + topic.Store(r.Header.Get("apns-topic")) |
| + pushType.Store(r.Header.Get("apns-push-type")) |
| + priority.Store(r.Header.Get("apns-priority")) |
| + var body map[string]any |
| + json.NewDecoder(r.Body).Decode(&body) |
| + payload.Store(body) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + if _, err := c.Push(t.Context(), "devicetoken123", "macos", "Bea O'Neill"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if path.Load() != "/3/device/devicetoken123" { |
| + t.Fatal("wrong path", path.Load()) |
| + } |
| + // The topic follows the platform: the two apps have different bundle |
| + // identifiers, and a token sent under the wrong one is refused with |
| + // DeviceTokenNotForTopic. |
| + if topic.Load() != "works.oficina.calls.mac" { |
| + t.Fatal("wrong topic for macos", topic.Load()) |
| + } |
| + if pushType.Load() != "alert" || priority.Load() != "10" { |
| + t.Fatal("wrong push type or priority", pushType.Load(), priority.Load()) |
| + } |
| + body := payload.Load().(map[string]any) |
| + aps := body["aps"].(map[string]any) |
| + alert := aps["alert"].(map[string]any) |
| + // The name is someone else's text and rides as a JSON value, never |
| + // built into one. |
| + if alert["body"] != "Bea O'Neill" { |
| + t.Fatal("name not carried verbatim", alert["body"]) |
| + } |
| +} |
| + |
| +func TestAnUnknownPlatformIsRefusedWithoutCallingApple(t *testing.T) { |
| + var called atomic.Int32 |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { called.Add(1) }) |
| + verdict, err := c.Push(t.Context(), "abc", "android", "Bea") |
| + if err == nil || verdict != Retry { |
| + t.Fatal("accepted an unknown platform") |
| + } |
| + if called.Load() != 0 { |
| + t.Fatal("called Apple for a platform with no topic") |
| + } |
| +} |
| + |
| +// The table that decides whether a token lives or dies. Pure, so every |
| +// reason Apple documents can be covered without a fake acting each out. |
| +func TestApplesRefusalsAreSortedIntoKeepAndDelete(t *testing.T) { |
| + for _, c := range []struct { |
| + status int |
| + reason string |
| + want Verdict |
| + }{ |
| + {410, "Unregistered", Gone}, |
| + {400, "BadDeviceToken", Gone}, |
| + {400, "DeviceTokenNotForTopic", Gone}, |
| + {410, "", Gone}, |
| + {403, "ExpiredProviderToken", Retry}, |
| + {403, "InvalidProviderToken", Retry}, |
| + {429, "TooManyRequests", Retry}, |
| + {503, "", Retry}, |
| + {500, "InternalServerError", Retry}, |
| + } { |
| + got, err := classify(c.status, c.reason) |
| + if got != c.want { |
| + t.Errorf("status %d reason %q: got %v, want %v", c.status, c.reason, got, c.want) |
| + } |
| + if err == nil { |
| + t.Errorf("status %d reason %q: refusal with no error", c.status, c.reason) |
| + } |
| + } |
| +} |
| + |
| +func TestApplesGoneVerdictReachesTheCaller(t *testing.T) { |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusGone) |
| + w.Write([]byte(`{"reason":"Unregistered"}`)) |
| + }) |
| + verdict, err := c.Push(t.Context(), "dead", "ios", "Bea") |
| + if verdict != Gone || err == nil { |
| + t.Fatal("a dead token must be reported as Gone", verdict, err) |
| + } |
| +} |
| + |
| +func TestAConfigMissingItsCredentialsIsRefusedAtConstruction(t *testing.T) { |
| + key, _ := testKey(t) |
| + full := Config{KeyID: "K", TeamID: "T", Key: key, Topics: map[string]string{"ios": "a"}} |
| + if _, err := New(full); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, broken := range map[string]Config{ |
| + "no key id": {TeamID: "T", Key: key, Topics: full.Topics}, |
| + "no team": {KeyID: "K", Key: key, Topics: full.Topics}, |
| + "no key": {KeyID: "K", TeamID: "T", Topics: full.Topics}, |
| + "no topics": {KeyID: "K", TeamID: "T", Key: key}, |
| + } { |
| + if _, err := New(broken); err == nil { |
| + t.Errorf("accepted a config with %s", name) |
| + } |
| + } |
| +} |
| + |
| +func TestUnreachableAppleIsRetryAndNotGone(t *testing.T) { |
| + // The distinction that matters: a network that is down must never be |
| + // read as "this device is gone", or an outage deletes every token. |
| + key, _ := testKey(t) |
| + c, err := New(Config{ |
| + KeyID: "K", TeamID: "T", Key: key, |
| + Topics: map[string]string{"ios": "a"}, |
| + Host: "http://127.0.0.1:1", |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + defer cancel() |
| + verdict, err := c.Push(ctx, "abc", "ios", "Bea") |
| + if verdict != Retry || err == nil { |
| + t.Fatal("an unreachable Apple must be Retry", verdict, err) |
| + } |
| +} |
diff --git a/internal/calls/app.go b/internal/calls/app.go| index cf30892..c2f4718 100644 |
| --- a/internal/calls/app.go |
| +++ b/internal/calls/app.go |
| @@ -18,6 +18,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/oficina/calls/internal/calls/apns" |
| "amadan.net/oficina/calls/internal/calls/hub" |
| "amadan.net/oficina/calls/internal/calls/relay" |
| "amadan.net/oficina/calls/internal/calls/seal" |
| @@ -36,6 +37,12 @@ type app struct { |
| linkBudget *budget |
| outsiderBudget *budget |
| hub *hub.Hub |
| + // push is the APNs sender, or nil where no key is configured — which |
| + // is every test and every deployment that has not been given one. |
| + // Nil is not a degraded mode: knocks still reach every member who is |
| + // in the room, exactly as before. It is only the member who is not |
| + // there who goes untold. |
| + push knockPusher |
| // hubSalt keys the session cookie into the hub's session id, so the |
| // hub holds no session material it did not have to. |
| hubSalt string |
| @@ -369,8 +376,22 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| } |
| // The hub reads standing back through the app, and never under its |
| // own lock: hubStanding is a database read. |
| + // The APNs sender, where a key was configured. Built before the hub |
| + // so that a.knocked has something to send with the first time it is |
| + // called, which on a busy instance can be immediately. |
| + if rc.apns != nil { |
| + client, err := apns.New(*rc.apns) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("calls: Apple push: %w", err) |
| + } |
| + a.push = client |
| + rc.Logger.Info("calls: Apple push configured", "sandbox", rc.APNsSandbox) |
| + } |
| + // The hub reads standing back through the app, and never under its |
| + // own lock: hubStanding is a database read. Knocked is the same |
| + // arrangement for the other direction. |
| a.hub = hub.New(hub.Config{ |
| - Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, |
| + Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, Knocked: a.knocked, |
| WaitingRoom: func(ctx context.Context, sqid string) (bool, error) { |
| m, _, err := st.meetingBySqid(ctx, sqid) |
| return m.WaitingRoom, err |
| @@ -473,6 +494,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| } |
| r.Post("/meetings/start", a.startCall) |
| r.Post("/native/meetings/start", a.nativeStartMeeting) |
| + r.Post("/native/devices", a.registerDevice) |
| r.Post("/meetings", a.createMeeting) |
| r.Post("/m/{sqid}/share", a.shareMeeting) |
| r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink) |
diff --git a/internal/calls/config.go b/internal/calls/config.go| index 428fd63..5903f98 100644 |
| --- a/internal/calls/config.go |
| +++ b/internal/calls/config.go |
| @@ -1,6 +1,7 @@ |
| package calls |
| import ( |
| + "amadan.net/oficina/calls/internal/calls/apns" |
| "encoding/base64" |
| "fmt" |
| "log/slog" |
| @@ -38,6 +39,29 @@ type Config struct { |
| // AllowedAddresses is the team: the addresses that are members. |
| // CALLS_ALLOWED_ADDRESSES, comma-separated. |
| AllowedAddresses []string |
| + |
| + // The Apple push key, for telling a member who is not in the room |
| + // that somebody is waiting. All four are required together or none |
| + // of them: a half-configured push is a boot-time mistake, not a |
| + // runtime surprise on the first knock. |
| + // |
| + // APNsKey is the .p8 Apple issued, PEM as downloaded, set with |
| + // `carlos secrets set` and never committed. CALLS_APNS_KEY. |
| + APNsKey string |
| + // CALLS_APNS_KEY_ID, the ten characters Apple names the key by. |
| + APNsKeyID string |
| + // CALLS_APNS_TEAM_ID, the Apple developer team. |
| + APNsTeamID string |
| + // CALLS_APNS_TOPIC_IOS and CALLS_APNS_TOPIC_MACOS: the two bundle |
| + // identifiers. Separate because a token is issued per app, and one |
| + // sent under the other app's topic is refused. |
| + APNsTopicIOS string |
| + APNsTopicMacOS string |
| + // CALLS_APNS_SANDBOX sends to Apple's sandbox host, which is where a |
| + // development build's tokens live. A production token refused by the |
| + // sandbox says nothing useful about why, so this is configuration |
| + // rather than something to detect. |
| + APNsSandbox bool |
| // Mailer delivers magic links. Nil outside development is refused; |
| // nil in development logs the link instead of sending it. |
| Mailer mail.Sender |
| @@ -67,6 +91,7 @@ type resolved struct { |
| instanceKey string |
| dek []byte |
| mailer mail.Sender |
| + apns *apns.Config |
| allow allowlist |
| relay []relay.Region |
| relayConfigured bool |
| @@ -134,6 +159,12 @@ func resolve(cfg Config) (resolved, error) { |
| } |
| out.allow = list |
| + push, err := resolveAPNs(cfg) |
| + if err != nil { |
| + return out, err |
| + } |
| + out.apns = push |
| + |
| regions, err := relay.Parse(cfg.Relay) |
| switch { |
| case err == nil: |
| @@ -163,3 +194,46 @@ func resolve(cfg Config) (resolved, error) { |
| } |
| return out, nil |
| } |
| + |
| +// resolveAPNs reads the push key, or reads that there isn't one. |
| +// |
| +// All five settings are required together. A key with no topic, or a |
| +// topic with no key, is somebody halfway through configuring push, and |
| +// the moment to say so is boot — not the first knock, in a goroutine, |
| +// into a log nobody is reading. |
| +// |
| +// No key at all is not an error and not a degraded mode. Knocks still |
| +// reach every member who is in the room, over the streams they always |
| +// used. It is only the member who is elsewhere who goes untold, which |
| +// is exactly where this app was before any of this existed. |
| +func resolveAPNs(cfg Config) (*apns.Config, error) { |
| + present := 0 |
| + for _, v := range []string{cfg.APNsKey, cfg.APNsKeyID, cfg.APNsTeamID, cfg.APNsTopicIOS, cfg.APNsTopicMacOS} { |
| + if strings.TrimSpace(v) != "" { |
| + present++ |
| + } |
| + } |
| + if present == 0 { |
| + return nil, nil |
| + } |
| + if present < 5 { |
| + return nil, fmt.Errorf("calls: Apple push is half-configured (%d of 5 settings present); set all of $CALLS_APNS_KEY, $CALLS_APNS_KEY_ID, $CALLS_APNS_TEAM_ID, $CALLS_APNS_TOPIC_IOS and $CALLS_APNS_TOPIC_MACOS, or none", present) |
| + } |
| + key, err := apns.ParseKey([]byte(cfg.APNsKey)) |
| + if err != nil { |
| + // The error names the shape and never the bytes. |
| + return nil, fmt.Errorf("calls: $CALLS_APNS_KEY is not usable: %w", err) |
| + } |
| + host := apns.HostProduction |
| + if cfg.APNsSandbox { |
| + host = apns.HostSandbox |
| + } |
| + return &apns.Config{ |
| + KeyID: strings.TrimSpace(cfg.APNsKeyID), TeamID: strings.TrimSpace(cfg.APNsTeamID), Key: key, |
| + Topics: map[string]string{ |
| + "ios": strings.TrimSpace(cfg.APNsTopicIOS), |
| + "macos": strings.TrimSpace(cfg.APNsTopicMacOS), |
| + }, |
| + Host: host, |
| + }, nil |
| +} |
diff --git a/internal/calls/hub/admission.go b/internal/calls/hub/admission.go| index 3a97e62..f736614 100644 |
| --- a/internal/calls/hub/admission.go |
| +++ b/internal/calls/hub/admission.go |
| @@ -260,6 +260,19 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error |
| p.Joined = true |
| p.JoinedAt = h.cfg.Now() |
| h.broadcastLocked(r, Message{Type: TypeKnock, From: p.Peer, Name: p.Name}, admittedMembers) |
| + // Who is already looking, gathered here because it needs the lock and |
| + // must not be re-read later: by the time the notification is sent the |
| + // room may have changed, and telling someone who *was* present is a |
| + // smaller fault than holding the mutex across a network call. |
| + if h.cfg.Knocked != nil { |
| + watching := make([]int64, 0, len(r.peers)) |
| + for _, other := range r.peers { |
| + if other.Admitted && other.Kind == KindMember { |
| + watching = append(watching, other.PersonID) |
| + } |
| + } |
| + go h.cfg.Knocked(r.sqid, watching, p.Name) |
| + } |
| return nil |
| } |
diff --git a/internal/calls/hub/hub.go b/internal/calls/hub/hub.go| index 22c579c..0ef0aed 100644 |
| --- a/internal/calls/hub/hub.go |
| +++ b/internal/calls/hub/hub.go |
| @@ -72,6 +72,17 @@ type Config struct { |
| // StandingFor re-derives a person's kind and whether they still hold |
| // entry authority for the room. ok=false means standing none. |
| StandingFor func(ctx context.Context, room string, personID int64) (kind Kind, ok bool, err error) |
| + // Knocked says that someone has begun waiting. It exists because the |
| + // admission queue lives only here, in memory, and reaches members |
| + // over their own streams — so a member who is not in the room has |
| + // never had any way to learn that somebody is at the door. |
| + // |
| + // Called on a goroutine of its own, never under the lock: the app's |
| + // implementation reads the database and talks to Apple, and the hub's |
| + // mutex is held across every peer in every room. Nil is no |
| + // notification, which is every test and every deployment without a |
| + // push key. |
| + Knocked func(room string, personIDs []int64, name string) |
| } |
| // Participation is the hub's record that a peer is in a room. It is the |
diff --git a/internal/calls/knockpush.go b/internal/calls/knockpush.go| new file mode 100644 |
| index 0000000..84c4277 |
| --- /dev/null |
| +++ b/internal/calls/knockpush.go |
| @@ -0,0 +1,132 @@ |
| +package calls |
| + |
| +import ( |
| + "context" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/oficina/calls/internal/calls/apns" |
| +) |
| + |
| +// maxDeviceToken is Apple's token length with room to spare: 32 bytes |
| +// today, 100 allowed since the format is not promised to stay put. |
| +const maxDeviceToken = 200 |
| + |
| +// knockPushDeadline bounds one round of notifications. A knock is only |
| +// interesting for as long as somebody is still standing at the door. |
| +const knockPushDeadline = 20 * time.Second |
| + |
| +// registerDevice is POST /native/devices: the app saying where it can be |
| +// reached when it is not running. |
| +// |
| +// Behind the same two locks as the other native write — the JSON content |
| +// type an HTML form cannot send, and the Origin csrf.Protect reads — |
| +// because like that one it runs on the session the app is holding rather |
| +// than on no authority at all. |
| +func (a *app) registerDevice(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + if r.Header.Get("Content-Type") != "application/json" { |
| + http.Error(w, "expected JSON", http.StatusUnsupportedMediaType) |
| + return |
| + } |
| + var input struct { |
| + Token string `json:"token"` |
| + Platform string `json:"platform"` |
| + } |
| + if json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&input) != nil { |
| + http.Error(w, "invalid request", http.StatusBadRequest) |
| + return |
| + } |
| + // Apple's tokens are hex. Insisting on that here means nothing |
| + // arbitrary is ever stored, and a token is never logged: it is an |
| + // address for a person's device and does not belong in a log line. |
| + token := strings.ToLower(strings.TrimSpace(input.Token)) |
| + if token == "" || len(token) > maxDeviceToken { |
| + http.Error(w, "invalid request", http.StatusBadRequest) |
| + return |
| + } |
| + if _, err := hex.DecodeString(token); err != nil { |
| + http.Error(w, "invalid request", http.StatusBadRequest) |
| + return |
| + } |
| + if input.Platform != "ios" && input.Platform != "macos" { |
| + http.Error(w, "invalid request", http.StatusBadRequest) |
| + return |
| + } |
| + p, ok := currentPerson(r) |
| + if !ok { |
| + http.Error(w, "sign in again", http.StatusUnauthorized) |
| + return |
| + } |
| + if err := a.store.saveDeviceToken(r.Context(), p.ID, token, input.Platform); err != nil { |
| + a.logger.Error("calls: register device", "err", err) |
| + http.Error(w, "cannot register this device", http.StatusInternalServerError) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| + |
| +// knocked is the hub's Knocked callback: somebody has begun waiting, and |
| +// the members who are not in the room have no other way to find out. |
| +// |
| +// `watching` is who was admitted at the moment of the knock. They are |
| +// skipped, because the queue is already on their screen and a push would |
| +// be a second telling of something they can see. That check is the whole |
| +// reason the hub gathers the list under its own lock and passes it in — |
| +// asking again here would race the room. |
| +// |
| +// Everything is best effort and nothing is retried. A knock is worth |
| +// saying once, quickly; a person who has stopped waiting is not worth |
| +// telling anyone about a minute later. |
| +func (a *app) knocked(room string, watching []int64, name string) { |
| + if a.push == nil { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), knockPushDeadline) |
| + defer cancel() |
| + members, err := a.store.memberPersonIDs(ctx, a.allowNow()) |
| + if err != nil { |
| + a.logger.Error("calls: knock push members", "err", err, "room", room) |
| + return |
| + } |
| + looking := make(map[int64]bool, len(watching)) |
| + for _, id := range watching { |
| + looking[id] = true |
| + } |
| + absent := members[:0:0] |
| + for _, id := range members { |
| + if !looking[id] { |
| + absent = append(absent, id) |
| + } |
| + } |
| + tokens, err := a.store.deviceTokensFor(ctx, absent) |
| + if err != nil { |
| + a.logger.Error("calls: knock push tokens", "err", err, "room", room) |
| + return |
| + } |
| + for _, device := range tokens { |
| + verdict, err := a.push.Push(ctx, device.Token, device.Platform, name) |
| + switch verdict { |
| + case apns.Gone: |
| + // Apple says this device will never be reachable again. The |
| + // row is wrong and deleting it is the only hygiene this |
| + // table gets. The token is not logged. |
| + if err := a.store.forgetDeviceToken(ctx, device.Token); err != nil { |
| + a.logger.Error("calls: forget device token", "err", err) |
| + } |
| + case apns.Retry: |
| + a.logger.Warn("calls: knock push refused", "err", err, "room", room, "platform", device.Platform) |
| + } |
| + } |
| +} |
| + |
| +// knockPusher is the narrow part of apns.Client this app uses, named as |
| +// an interface so a test can watch what would have been sent to Apple |
| +// without an Apple to send it to. |
| +type knockPusher interface { |
| + Push(ctx context.Context, token, platform, name string) (apns.Verdict, error) |
| +} |
diff --git a/internal/calls/knockpush_test.go b/internal/calls/knockpush_test.go| new file mode 100644 |
| index 0000000..06bcf42 |
| --- /dev/null |
| +++ b/internal/calls/knockpush_test.go |
| @@ -0,0 +1,206 @@ |
| +package calls |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "amadan.net/oficina/calls/internal/calls/apns" |
| +) |
| + |
| +// recordingPusher stands in for Apple. The verdict is per token so one |
| +// test can have a live device and a dead one at the same time. |
| +type recordingPusher struct { |
| + mu sync.Mutex |
| + sent []string |
| + verdicts map[string]apns.Verdict |
| +} |
| + |
| +func (p *recordingPusher) Push(ctx context.Context, token, platform, name string) (apns.Verdict, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + p.sent = append(p.sent, token+"|"+platform+"|"+name) |
| + if v, ok := p.verdicts[token]; ok { |
| + if v == apns.Gone { |
| + return v, context.Canceled |
| + } |
| + return v, nil |
| + } |
| + return apns.Delivered, nil |
| +} |
| + |
| +func (p *recordingPusher) all() []string { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return append([]string(nil), p.sent...) |
| +} |
| + |
| +func deviceJSON(f *homeRouterFixture, body string) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader(body)) |
| + r.Header.Set("Content-Type", "application/json") |
| + r.Header.Set("Origin", homeTestOrigin) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + return w |
| +} |
| + |
| +func TestRegisteringADeviceStoresItAndIsIdempotent(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + if w := deviceJSON(f, `{"token":"AABBCC0011","platform":"macos"}`); w.Code != http.StatusNoContent { |
| + t.Fatal("refused", w.Code, w.Body.String()) |
| + } |
| + var count int |
| + if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil { |
| + t.Fatal(err) |
| + } |
| + if count != 1 { |
| + t.Fatal("expected one row", count) |
| + } |
| + // The app re-registers on every launch. That must move last_seen, |
| + // not accumulate rows. |
| + if w := deviceJSON(f, `{"token":"aabbcc0011","platform":"macos"}`); w.Code != http.StatusNoContent { |
| + t.Fatal("re-registration refused", w.Code) |
| + } |
| + if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil { |
| + t.Fatal(err) |
| + } |
| + if count != 1 { |
| + t.Fatal("re-registration made a second row", count) |
| + } |
| + var stored string |
| + f.app.db.Writer().QueryRow("SELECT token FROM device_tokens").Scan(&stored) |
| + if stored != "aabbcc0011" { |
| + t.Fatal("token not normalised to lower case", stored) |
| + } |
| +} |
| + |
| +func TestADeviceRegistrationMustBeHexAndAKnownPlatform(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + for name, body := range map[string]string{ |
| + "not hex": `{"token":"zzzz","platform":"ios"}`, |
| + "empty token": `{"token":"","platform":"ios"}`, |
| + "no platform": `{"token":"aabb"}`, |
| + "bad platform": `{"token":"aabb","platform":"android"}`, |
| + "too long": `{"token":"` + strings.Repeat("ab", 200) + `","platform":"ios"}`, |
| + "not json": `hello`, |
| + } { |
| + if w := deviceJSON(f, body); w.Code == http.StatusNoContent { |
| + t.Errorf("accepted %s", name) |
| + } |
| + } |
| + var count int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) |
| + if count != 0 { |
| + t.Fatal("a refused registration still stored something", count) |
| + } |
| +} |
| + |
| +func TestADeviceRegistrationNeedsBothLocksAndASession(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + // A form content type is the cross-site shape an HTML form can send. |
| + form := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader("")) |
| + form.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + form.Header.Set("Origin", homeTestOrigin) |
| + for _, c := range f.cookies { |
| + form.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, form) |
| + if w.Code != http.StatusUnsupportedMediaType { |
| + t.Fatal("form post accepted", w.Code) |
| + } |
| + // And no origin is csrf.Protect's refusal, before the handler. |
| + bare := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader(`{"token":"aabb","platform":"ios"}`)) |
| + bare.Header.Set("Content-Type", "application/json") |
| + for _, c := range f.cookies { |
| + bare.AddCookie(c) |
| + } |
| + w = httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, bare) |
| + if w.Code == http.StatusNoContent { |
| + t.Fatal("origin-less registration accepted") |
| + } |
| + // Signed out entirely. |
| + g := newHomeRouterFixture(t) |
| + if w := deviceJSON(g, `{"token":"aabb","platform":"ios"}`); w.Code == http.StatusNoContent { |
| + t.Fatal("signed-out registration accepted") |
| + } |
| +} |
| + |
| +// The decision this whole feature turns on: who is told. |
| +func TestAKnockSkipsMembersWhoAreAlreadyLooking(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + if w := deviceJSON(f, `{"token":"aabbcc","platform":"macos"}`); w.Code != http.StatusNoContent { |
| + t.Fatal(w.Code) |
| + } |
| + var personID int64 |
| + if err := f.app.db.Writer().QueryRow("SELECT person_id FROM device_tokens").Scan(&personID); err != nil { |
| + t.Fatal(err) |
| + } |
| + pusher := &recordingPusher{} |
| + f.app.push = pusher |
| + |
| + // In the room: the queue is on their screen already, and a push |
| + // would be a second telling of something they can see. |
| + f.app.knocked("room1", []int64{personID}, "Bea") |
| + if got := pusher.all(); len(got) != 0 { |
| + t.Fatal("pushed to a member who was watching", got) |
| + } |
| + |
| + // Not in the room: this is the case the feature exists for, and the |
| + // case that was impossible before it. |
| + f.app.knocked("room1", nil, "Bea") |
| + got := pusher.all() |
| + if len(got) != 1 || got[0] != "aabbcc|macos|Bea" { |
| + t.Fatal("absent member was not told", got) |
| + } |
| +} |
| + |
| +func TestAKnockDeletesATokenAppleSaysIsGone(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + deviceJSON(f, `{"token":"deadbeef","platform":"ios"}`) |
| + f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"deadbeef": apns.Gone}} |
| + f.app.knocked("room1", nil, "Bea") |
| + var count int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) |
| + if count != 0 { |
| + t.Fatal("a token Apple reported unregistered was kept", count) |
| + } |
| +} |
| + |
| +func TestAKnockKeepsATokenWhenAppleIsMerelyUnreachable(t *testing.T) { |
| + // The distinction that matters: an outage must never be read as "the |
| + // device is gone", or one bad afternoon empties the table. |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + deviceJSON(f, `{"token":"abcdef","platform":"ios"}`) |
| + f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"abcdef": apns.Retry}} |
| + f.app.knocked("room1", nil, "Bea") |
| + var count int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) |
| + if count != 1 { |
| + t.Fatal("a retryable refusal deleted the token", count) |
| + } |
| +} |
| + |
| +func TestNoPushKeyIsQuietRatherThanBroken(t *testing.T) { |
| + // Nil push is every deployment without a key, and it must be a |
| + // no-op: knocks still reach everyone in the room over their streams, |
| + // exactly as before any of this existed. |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + deviceJSON(f, `{"token":"aabb","platform":"ios"}`) |
| + f.app.push = nil |
| + f.app.knocked("room1", nil, "Bea") |
| +} |
diff --git a/internal/calls/migrations/0005_create_device_tokens.sql b/internal/calls/migrations/0005_create_device_tokens.sql| new file mode 100644 |
| index 0000000..9a055ae |
| --- /dev/null |
| +++ b/internal/calls/migrations/0005_create_device_tokens.sql |
| @@ -0,0 +1,6 @@ |
| +CREATE TABLE `device_tokens` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`token` text NOT NULL,`platform` text NOT NULL,`created_at` datetime,`last_seen` datetime); |
| + |
| +CREATE UNIQUE INDEX `idx_device_tokens_token` ON `device_tokens`(`token`); |
| + |
| +CREATE INDEX `idx_device_tokens_person_id` ON `device_tokens`(`person_id`); |
| + |
diff --git a/internal/calls/migrations/schema.sql b/internal/calls/migrations/schema.sql| index 9147b74..e5030d4 100644 |
| --- a/internal/calls/migrations/schema.sql |
| +++ b/internal/calls/migrations/schema.sql |
| @@ -3,6 +3,8 @@ |
| CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text NOT NULL,`operator_person_id` integer NOT NULL,`token_hash` text NOT NULL,`created_at` datetime,`revoked_at` datetime); |
| +CREATE TABLE `device_tokens` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`token` text NOT NULL,`platform` text NOT NULL,`created_at` datetime,`last_seen` datetime); |
| + |
| CREATE TABLE `guest_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`person_id` integer NOT NULL,`granted_at` datetime,`expires_at` datetime,`admitted_at` datetime,`admitted_by` text); |
| CREATE TABLE `meeting_defaults` (`id` integer PRIMARY KEY AUTOINCREMENT,`require_signin` numeric NOT NULL DEFAULT false,`waiting_room` numeric NOT NULL DEFAULT false); |
| @@ -19,6 +21,10 @@ CREATE INDEX `idx_agents_operator_person_id` ON `agents`(`operator_person_id`); |
| CREATE UNIQUE INDEX `idx_agents_token_hash` ON `agents`(`token_hash`); |
| +CREATE INDEX `idx_device_tokens_person_id` ON `device_tokens`(`person_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_device_tokens_token` ON `device_tokens`(`token`); |
| + |
| CREATE UNIQUE INDEX `idx_grant_meeting_person` ON `guest_grants`(`meeting_id`,`person_id`); |
| CREATE INDEX `idx_guest_grants_expires_at` ON `guest_grants`(`expires_at`); |
diff --git a/internal/calls/models.go b/internal/calls/models.go| index 6a50f82..6dbf30c 100644 |
| --- a/internal/calls/models.go |
| +++ b/internal/calls/models.go |
| @@ -14,6 +14,7 @@ var Models = []any{ |
| &PendingJoin{}, |
| &Agent{}, |
| &MeetingDefaults{}, |
| + &DeviceToken{}, |
| } |
| // Person is a verified identity or an anonymous guest. Anonymous guests carry |
| @@ -60,6 +61,35 @@ type MeetingLink struct { |
| RevokedAt *time.Time |
| } |
| +// DeviceToken is one Apple device a person can be reached on when the |
| +// app is not running. It is the only part of the knock notification that |
| +// has to be stored: the queue itself lives in the hub's memory and dies |
| +// with the process, which is exactly why a person outside the room could |
| +// never be told about it. |
| +// |
| +// The token is stored in plaintext, unlike a meeting title. It is not a |
| +// secret about a person — it is an address Apple issues, scoped to one |
| +// app on one device, useless without the push key, and rotated by the |
| +// system whenever it likes. Sealing it would cost the sweep the ability |
| +// to delete by token when Apple reports one unregistered, which is the |
| +// one hygiene operation this table needs. |
| +// |
| +// Unique on the token rather than on (person, token): Apple reissues a |
| +// token to whoever installs the app next, so the same string arriving |
| +// for a different person means the device changed hands and the old row |
| +// is wrong, not that there are two. |
| +type DeviceToken struct { |
| + ID int64 |
| + PersonID int64 `gorm:"index;not null"` |
| + Token string `gorm:"uniqueIndex;not null"` |
| + // "ios" or "macos". Apple keeps one token space per app per device, |
| + // but the two apps have different bundle identifiers and therefore |
| + // different APNs topics, so a token is useless without knowing which. |
| + Platform string `gorm:"not null"` |
| + CreatedAt time.Time |
| + LastSeen time.Time |
| +} |
| + |
| // GuestGrant is a non-member's entry authority for one meeting. One |
| // row per person per meeting. AdmittedAt and AdmittedBy are an audit of |
| // the first admission (slice 2) and confer nothing. |
diff --git a/internal/calls/store.go b/internal/calls/store.go| index 8a9732d..6f69cad 100644 |
| --- a/internal/calls/store.go |
| +++ b/internal/calls/store.go |
| @@ -665,3 +665,54 @@ func (s *store) createGuest(ctx context.Context, name string) (Person, error) { |
| err := s.g.WithContext(ctx).Create(&p).Error |
| return p, err |
| } |
| + |
| +// saveDeviceToken records where a person can be reached when the app is |
| +// not running, and is an upsert on the token rather than on the person: |
| +// Apple reissues a token to whoever installs the app next, so the same |
| +// string arriving under a different person means the device changed |
| +// hands and the old row is wrong. LastSeen moves on every registration, |
| +// which is what lets a token that has gone quiet be swept later. |
| +func (s *store) saveDeviceToken(ctx context.Context, personID int64, token, platform string) error { |
| + now := s.now().UTC() |
| + return s.g.WithContext(ctx).Clauses(clause.OnConflict{ |
| + Columns: []clause.Column{{Name: "token"}}, |
| + DoUpdates: clause.AssignmentColumns([]string{"person_id", "platform", "last_seen"}), |
| + }).Create(&DeviceToken{ |
| + PersonID: personID, Token: token, Platform: platform, CreatedAt: now, LastSeen: now, |
| + }).Error |
| +} |
| + |
| +// deviceTokensFor answers every device a set of people can be reached |
| +// on. Plural because one person has a Mac and a phone, and a knock is |
| +// worth saying on both — whichever they are near. |
| +func (s *store) deviceTokensFor(ctx context.Context, personIDs []int64) ([]DeviceToken, error) { |
| + if len(personIDs) == 0 { |
| + return nil, nil |
| + } |
| + var tokens []DeviceToken |
| + err := s.g.WithContext(ctx).Where("person_id IN ?", personIDs).Find(&tokens).Error |
| + return tokens, err |
| +} |
| + |
| +// forgetDeviceToken deletes one token, which is the only correct answer |
| +// to Apple reporting it unregistered. Deleting by token and not by |
| +// person: the device is gone, the person is not. |
| +func (s *store) forgetDeviceToken(ctx context.Context, token string) error { |
| + return s.g.WithContext(ctx).Where("token = ?", token).Delete(&DeviceToken{}).Error |
| +} |
| + |
| +// memberPersonIDs is everyone who may admit: the people whose address is |
| +// on the team allowlist. A knock is theirs to answer, and nobody else's. |
| +func (s *store) memberPersonIDs(ctx context.Context, allowed allowlist) ([]int64, error) { |
| + var persons []Person |
| + if err := s.g.WithContext(ctx).Where("email NOT LIKE ?", "guest:%").Find(&persons).Error; err != nil { |
| + return nil, err |
| + } |
| + ids := make([]int64, 0, len(persons)) |
| + for _, p := range persons { |
| + if allowed.allows(p.Email) { |
| + ids = append(ids, p.ID) |
| + } |
| + } |
| + return ids, nil |
| +} |
diff --git a/native/README.md b/native/README.md| index d442e03..5f6a204 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -71,13 +71,20 @@ not frontmost. The admission queue is already on screen for a member who is |
| looking at the call; this is for the member who is in another Space, |
| another app, or has the phone in a pocket. |
| -These are **local** notifications, and that is the whole of what is |
| -possible here today: there is no APNs anywhere in this suite — no push |
| -key, no device token registration, no sender — so a member whose app is |
| -*closed* is not reached. That case needs server-side push and a decision |
| -about whether a host who is not in the meeting can be notified at all, |
| -since the waiting queue lives only in the hub's memory and reaches members |
| -over SSE. |
| +A knock also reaches a member whose app is **closed**, where the server |
| +has an Apple push key. The app registers its device token at join |
| +(`POST /native/devices`); the hub calls back when someone begins waiting; |
| +the server pushes to every member who was *not* admitted in that room at |
| +that moment, because the ones who were can already see the queue. |
| + |
| +Without a key the server sends nothing and says nothing, which is where |
| +this app was before push existed: knocks still reach everyone in the room |
| +over their own streams. It is configured with `CALLS_APNS_KEY` (the .p8 |
| +as Apple issues it, via `carlos secrets set`), `CALLS_APNS_KEY_ID`, |
| +`CALLS_APNS_TEAM_ID`, `CALLS_APNS_TOPIC_IOS`, `CALLS_APNS_TOPIC_MACOS` |
| +and optionally `CALLS_APNS_SANDBOX`. All five of the first are required |
| +together — a half-configured push is refused at boot rather than |
| +discovered on the first knock. |
| Someone who arrived while the queue was visible is never announced later, |
| so tabbing away does not produce a pile of notifications about people who |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 55909c3..9235300 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -101,6 +101,10 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| currentCamera = media.currentCamera |
| audio.refresh() |
| KnockNotice.requestPermission() |
| + // Asking Apple at join, once there is a session to register the |
| + // answer against. Apple replies on its own schedule, at the app |
| + // delegate, and may never reply at all. |
| + RemoteKnocks.register() |
| try client.start() |
| } |
| public func close() { |
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift| index 555ba08..c1fb867 100644 |
| --- a/native/apple/Sources/CallsKit/CallsModel.swift |
| +++ b/native/apple/Sources/CallsKit/CallsModel.swift |
| @@ -197,6 +197,27 @@ import Foundation |
| } |
| } |
| } |
| + /// Hands the server an Apple device token, so a knock reaches this |
| + /// device when the app is closed. Called from the app delegate when |
| + /// Apple answers, which may be long after the join that asked, and |
| + /// may never happen at all. |
| + /// |
| + /// Silent on failure on purpose: nobody asked for this and there is |
| + /// nothing they could do about it. The consequence of failing is no |
| + /// push, which is what they had before push existed. |
| + public func registerDevice(token: Data) { |
| + guard signedIn, !origin.isEmpty, !cookies.isEmpty else { return } |
| + let origin = self.origin, cookies = self.cookies |
| + let hex = RemoteKnocks.hex(token) |
| + let platform = RemoteKnocks.platform |
| + Task.detached { |
| + // gomobile renders a func returning only error as BOOL plus an |
| + // NSErrorPointer. Both are dropped: see above. |
| + var error: NSError? |
| + BridgeRegisterDevice(origin, cookies, hex, platform, &error) |
| + } |
| + } |
| + |
| public func join() { |
| callEpoch = UUID() |
| let operation = callEpoch |
diff --git a/native/apple/Sources/CallsKit/RemoteKnocks.swift b/native/apple/Sources/CallsKit/RemoteKnocks.swift| new file mode 100644 |
| index 0000000..030e551 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/RemoteKnocks.swift |
| @@ -0,0 +1,51 @@ |
| +import Foundation |
| + |
| +#if os(iOS) |
| + import UIKit |
| +#else |
| + import AppKit |
| +#endif |
| + |
| +/// Registering this device with Apple, and then with Calls, so that a |
| +/// knock can reach it when the app is closed. |
| +/// |
| +/// The local half — KnockWatch — only reaches an app that is running. |
| +/// This is the other half, and it is the only one that can help the |
| +/// person whose Mac is asleep or whose phone is in a pocket with Calls |
| +/// not open. |
| +/// |
| +/// Everything here is best effort and nothing is ever shown to anyone. A |
| +/// device that could not register is a device that will not be told |
| +/// about a knock, which is exactly where every device was before this |
| +/// existed. It is never a reason to fail a join, and never a sheet. |
| +@MainActor public enum RemoteKnocks { |
| + /// The platform name the server stores alongside the token. The two |
| + /// apps have different bundle identifiers and therefore different APNs |
| + /// topics, so a token is useless without it. |
| + public static var platform: String { |
| + #if os(iOS) |
| + return "ios" |
| + #else |
| + return "macos" |
| + #endif |
| + } |
| + |
| + /// Asks Apple for a device token. The answer arrives at the app |
| + /// delegate, asynchronously and possibly never — without the push |
| + /// entitlement, or on a build Apple will not issue one for, the |
| + /// failure callback is what comes back instead. |
| + public static func register() { |
| + #if os(iOS) |
| + UIApplication.shared.registerForRemoteNotifications() |
| + #else |
| + NSApplication.shared.registerForRemoteNotifications() |
| + #endif |
| + } |
| + |
| + /// Apple's token is opaque bytes; the server stores it as lower-case |
| + /// hex, which is the form every APNs provider uses and the form the |
| + /// registration endpoint validates. |
| + public static func hex(_ token: Data) -> String { |
| + token.map { String(format: "%02x", $0) }.joined() |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index c059dfa..1f4daf9 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -17,6 +17,7 @@ import WebRTC |
| Window("Calls", id: "main") { |
| ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } |
| .onOpenURL { model.openMeetingURL($0) } |
| + .onAppear { CallsAppDelegate.model = model } |
| } |
| .defaultSize(width: 1000, height: 760) |
| .commands { |
| @@ -27,6 +28,25 @@ import WebRTC |
| } |
| final class CallsAppDelegate: NSObject, NSApplicationDelegate { |
| + /// Set by the app so Apple's answer can reach the session that asked. |
| + /// Weak-ish by convention rather than by keyword: the model outlives |
| + /// the delegate for the whole life of the process. |
| + @MainActor static var model: CallsModel? |
| + |
| + func application( |
| + _ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data |
| + ) { |
| + Task { @MainActor in CallsAppDelegate.model?.registerDevice(token: token) } |
| + } |
| + |
| + func application( |
| + _ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error |
| + ) { |
| + // Expected on any build without the push entitlement, which is every |
| + // ad-hoc signed local build. Silent: the person did not ask for this |
| + // and the consequence is simply no push. |
| + } |
| + |
| func applicationDidFinishLaunching(_ notification: Notification) { |
| if let url = Bundle.main.url(forResource: "MacIcon", withExtension: "png") |
| ?? Bundle.module.url(forResource: "MacIcon", withExtension: "png") |
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go| index 0d30843..8784a66 100644 |
| --- a/native/bridge/bridge.go |
| +++ b/native/bridge/bridge.go |
| @@ -103,3 +103,15 @@ func (c *Client) Send(typ, to string, generation int64, payload string) error { |
| } |
| func (c *Client) Leave() { c.room.Leave() } |
| func (c *Client) Close() { c.room.Close() } |
| + |
| +// RegisterDevice hands the server an Apple device token so a knock can |
| +// reach this device when the app is closed. Best effort: the caller |
| +// ignores the error beyond logging, because a device that did not |
| +// register is simply one that will not be pushed to. |
| +func RegisterDevice(origin, cookiesJSON, token, platform string) error { |
| + jar, err := sessionJar(origin, cookiesJSON) |
| + if err != nil { |
| + return err |
| + } |
| + return callscore.RegisterDevice(context.Background(), origin, &http.Client{Jar: jar}, token, platform) |
| +} |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index d094e07..6bad8fd 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -2,13 +2,35 @@ import CallsKit |
| import SwiftUI |
| import WebRTC |
| +/// The delegate exists for one reason: Apple hands a device token to |
| +/// the application delegate and nowhere else, so a pure SwiftUI app has |
| +/// no way to receive one. |
| +final class CallsPhoneDelegate: NSObject, UIApplicationDelegate { |
| + @MainActor static var model: CallsModel? |
| + |
| + func application( |
| + _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data |
| + ) { |
| + Task { @MainActor in CallsPhoneDelegate.model?.registerDevice(token: token) } |
| + } |
| + |
| + func application( |
| + _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error |
| + ) { |
| + // Expected in the simulator and on any build without the push |
| + // entitlement. Silent, for the same reason as on the Mac. |
| + } |
| +} |
| + |
| @main struct CallsPhoneApp: App { |
| + @UIApplicationDelegateAdaptor(CallsPhoneDelegate.self) private var appDelegate |
| @StateObject private var model = CallsModel() |
| @Environment(\.scenePhase) private var scenePhase |
| var body: some Scene { |
| WindowGroup { |
| PhoneHome(model: model) |
| .onOpenURL { model.openMeetingURL($0) } |
| + .onAppear { CallsPhoneDelegate.model = model } |
| .onChange(of: scenePhase) { _, phase in |
| // This milestone is foreground-only. Browser sign-in is kept alive; |
| // an active call leaves cleanly instead of silently sending audio. |