Skip to content

Ring linked devices (iPad) by attaching them to the push relay - #52

Merged
m-radar merged 1 commit into
mainfrom
feat/relay-linked-device-attach
Oct 2, 2026
Merged

m-radar merged 1 commit into
mainfrom
feat/relay-linked-device-attach

Conversation

@CakeWallet

Copy link
Copy Markdown
Contributor

Problem

On iPad, calls don't ring while Radar is in the background or the iPad is locked. Message notifications don't arrive either. On iPhone, both work.

  • Why the relay matters: the bundle prefix is com.cakelabs, so Signal's APNs pushes can't reach Radar. RadarPushRelay is the only thing that wakes the app.
  • Why the iPhone works: the relay relies on the primary provisioning a phantom linked device.
  • Why the iPad fails: an iPad is always a linked device, and the chat server only issues device-provisioning codes to the primary. On the iPad, runEnsure registered its APNs token with the relay, then failed at requestDeviceProvisioningCode() on every launch.
    • The relay never linked anything for the iPad, so nothing woke it.
    • Each failed attempt also used up the account's device-provisioning rate limit, which the iPhone needs to re-provision its phantom.

The call stack itself is fine. RingRTC and the Signal/Calls code are unchanged from upstream. Calls never arrived because the app was never woken.

Change

One file: Signal/Notifications/RadarPushRelay.swift.

  • Primary devices: unchanged.
  • Linked devices (e.g. iPad) now go through runEnsureAttached:
    1. register their APNs token with the relay (POST /devices), as before;
    2. attach that registration to the account's existing relay device (POST /attach, added in radar-labs/radar-push-relay#1). The relay then pings this device whenever it pings the primary;
    3. keep the relay's copy of the APNs token current.
  • Signing: the attach request is signed with the account's ACI identity key, which every device on the account holds. The signed message is:
    RadarPushRelayAttach-v1\n<aci>\n<device_id>\n<relay_token>\n<timestamp_ms>
    
    It must stay byte-identical to signed_message in the relay. The relay's tests check it against signatures made with libsignal.
  • Credentials: the iPad's own Signal credentials are never sent to the relay.
  • Re-checks: the attachment is confirmed via /status (attached) and redone if the relay dropped it. A relay that has forgotten the token (404) triggers re-registration.
  • Deploy order: a relay without /attach answers 404. The app logs that and retries on the next launch or token update, so the app and relay can ship in either order.
  • No UI changes: the existing relay prompt during linking and the Settings toggle now actually do something on iPad. There are no new strings, so no localization changes are needed.

Blast radius (per CLAUDE.md)

  • Callers: ensure/setEnabled → RelayWorker → runEnsure. That's called from AppDelegate (launch), PushRegistrationManager (APNs token received), NotificationSettingsViewController (toggle), and askIfNeeded (registration and linking prompts). All of these are unchanged; the new branch only runs when !registrationState.isRegisteredPrimaryDevice.
  • Primary path: byte-for-byte unchanged apart from that early branch. The branch happens after the existing isEnabled and APNs-token checks, so a linked device with the relay disabled still does nothing.
  • State: adds a new isAttached key in the RadarPushRelay KV collection, defaulting to false. tearDown clears it.
  • Existing iPads that already have a relay token from the old failing path reuse that token and simply attach. No migration is needed.
  • Can't verify locally: behaviour against the deployed relay, and on a real iPad.

Testing

  • Simulator build: Signal scheme, Debug, Xcode 27. BUILD SUCCEEDED, and RadarPushRelay.swift compiled with no warnings or errors.
    • Getting there needed local-only workarounds for problems that already exist on main and are unrelated to this PR. See the note below.
  • The relay side is unit-tested in radar-labs/radar-push-relay#1, including XEdDSA verification against real libsignal signatures.
  • Not yet tested on devices. After both PRs are live:
    1. iPhone (primary) has the relay enabled.
    2. On the iPad, enable the relay. The debug log should show RadarPushRelay: attached linked device to account relay.
    3. Lock the iPad and call the account from another phone. Both the iPhone and the iPad should ring.

Note: main doesn't build with Xcode 27

Unrelated to this PR, but anyone moving to Xcode 27 will hit these:

  1. Warnings-as-errors. Swift 6.3 adds new diagnostics (NoUseUnstructuredThrowingTask, ImplicitStrongCapture), and Project.xcconfig turns warnings into errors. Workaround: Config/User.xcconfig from the sample.
  2. "ambiguous use of 'run'". This hits every ConcurrentTaskQueue.run { … } / KeyedConcurrentTaskQueue.run { … } call with a non-throwing closure. Adding @_disfavoredOverload to the throwing run<T>(_ block: () async throws -> T) overloads in both files fixes it without changing behaviour. I applied that only locally for the compile check.
  3. Metal toolchain. It has to be installed separately: xcodebuild -downloadComponent MetalToolchain.

🤖 Generated with Claude Code

A linked iPad never received relay pings, so incoming calls (and message
notifications) didn't arrive while Radar was backgrounded or the iPad was
locked. Signal's own APNs pushes can't reach Radar's bundle ID, leaving
the relay as the only wake-up path.

The relay works by the primary provisioning a phantom linked device, but
the chat server only issues device-provisioning codes to the primary.
On a linked device runEnsure registered its APNs token with the relay and
then failed at requestDeviceProvisioningCode() on every launch (also
spending the account's device-provisioning rate limit).

Linked devices now take a separate path (runEnsureAttached):
1. register their APNs token with the relay (POST /devices),
2. attach that registration to the account's existing relay device
   (POST /attach), signed with the account's ACI identity key over
   "RadarPushRelayAttach-v1\n<aci>\n<device_id>\n<relay_token>\n<ts_ms>",
3. keep the relay's copy of the APNs token current.

The device's own Signal credentials are never sent to the relay. The
attachment is re-checked via /status (`attached`) and redone if the
relay dropped it. A relay without /attach answers 404, which is logged
and retried on the next launch, so app and relay can ship in either
order. Primary devices are unchanged.

Requires radar-labs/radar-push-relay#1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@m-radar
m-radar merged commit dbd4419 into main Oct 2, 2026
0 of 3 checks passed
@m-radar
m-radar deleted the feat/relay-linked-device-attach branch October 2, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants