From d8b29cd6f2e4630a9be1a445f31461e0c822da1c Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 27 Jul 2026 11:30:43 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20register=20native=20APNs/FCM=20devi?= =?UTF-8?q?ce=20token=20on=20push.native=20(P2=20push,=20slice=203a=20?= =?UTF-8?q?=E2=80=94=20mobile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes slice 3a's mobile half. When the daemon advertises push.native (transport native/relay — it sends via APNs/FCM directly), the client now registers this device's NATIVE push token (getDevicePushTokenAsync) instead of the Expo token. Expo transport (capability "push") is unchanged. - push-protocol.ts: PUSH_NATIVE_CAPABILITY = "push.native". - push.ts registerForPush: pick the token type by the advertised capability — native (raw APNs/FCM token, no EAS projectId) for push.native, Expo for push; register nothing if neither is advertised. Backward-compatible. Verified: tsc + eslint clean; expo export (web) bundles 1020 modules. Device e2e (real APNs/FCM delivery) needs a dev build + a daemon running transport native with your .p8 + Firebase creds. Signed-off-by: Yash Datta Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/push-protocol.ts | 6 ++++++ src/lib/push.ts | 31 +++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/lib/push-protocol.ts b/src/lib/push-protocol.ts index 710e6c0..3ceb2f0 100644 --- a/src/lib/push-protocol.ts +++ b/src/lib/push-protocol.ts @@ -16,6 +16,12 @@ import type { ClientMessage } from "@codeoid/protocol"; * only when a push transport is configured. Matches `CAPABILITIES.PUSH`. */ export const PUSH_CAPABILITY = "push"; +/** Advertised by the daemon when it delivers via NATIVE APNs/FCM tokens + * (transport `native` or `relay`) rather than Expo — the client then registers + * its `getDevicePushTokenAsync` token instead of the Expo token. Matches + * `CAPABILITIES.PUSH_NATIVE`. */ +export const PUSH_NATIVE_CAPABILITY = "push.native"; + export type PushPlatform = "ios" | "android"; export interface PushRegisterMsg { diff --git a/src/lib/push.ts b/src/lib/push.ts index 362688e..842ac6b 100644 --- a/src/lib/push.ts +++ b/src/lib/push.ts @@ -19,7 +19,12 @@ import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; import type { Connection } from "./connection"; -import { PUSH_CAPABILITY, sendPushMsg, type PushPlatform } from "./push-protocol"; +import { + PUSH_CAPABILITY, + PUSH_NATIVE_CAPABILITY, + sendPushMsg, + type PushPlatform, +} from "./push-protocol"; /** Token registered this session, so sign-out can unregister exactly it. */ let registeredToken: string | null = null; @@ -52,9 +57,13 @@ export async function registerForPush( ): Promise { const platform = nativePlatform(); if (!platform) return; - // Only register when the daemon will actually deliver — avoids a needless - // permission prompt against a daemon with no push transport configured. - if (!daemonCapabilities?.includes(PUSH_CAPABILITY)) return; + // Which token type does the daemon want? `push.native` (transport native/relay + // → APNs/FCM directly) wants this device's NATIVE token; `push` (transport + // expo) wants an Expo token. Neither advertised → the daemon has no push + // transport configured, so skip (avoids a needless permission prompt). + const wantsNative = daemonCapabilities?.includes(PUSH_NATIVE_CAPABILITY) ?? false; + const wantsExpo = daemonCapabilities?.includes(PUSH_CAPABILITY) ?? false; + if (!wantsNative && !wantsExpo) return; if (!Device.isDevice) return; // simulators/emulators can't get a push token try { @@ -64,10 +73,16 @@ export async function registerForPush( } if (!perm.granted) return; - const projectId = easProjectId(); - const { data: token } = await Notifications.getExpoPushTokenAsync( - projectId ? { projectId } : undefined, - ); + let token: string; + if (wantsNative) { + // Raw APNs (iOS) / FCM (Android) device token — the self-hosted daemon or + // relay sends via APNs/FCM directly. No EAS projectId needed. + const device = await Notifications.getDevicePushTokenAsync(); + token = typeof device.data === "string" ? device.data : String(device.data); + } else { + const projectId = easProjectId(); + token = (await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined)).data; + } if (conn.client.status.kind !== "connected") return; sendPushMsg(conn.client, { type: "push.register", id: conn.client.nextId(), token, platform }); registeredToken = token; From 7a1ddfed6e32f64acc06864ee7abb39a12b9501c Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Wed, 5 Aug 2026 01:17:21 +0800 Subject: [PATCH 2/2] fix: clarify DevicePushToken.data coercion is intentional (any-typed) Address Oracle review on push.ts: getDevicePushTokenAsync().data is typed `any` via the ImplicitlySupportedDevicePushToken union arm (not `string`), so the runtime typeof coercion guarantees a string rather than assigning an `any` to `token: string`. Document why so it doesn't read as redundant. Signed-off-by: Yash Datta Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/push.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/push.ts b/src/lib/push.ts index 842ac6b..1bb1e2b 100644 --- a/src/lib/push.ts +++ b/src/lib/push.ts @@ -77,6 +77,8 @@ export async function registerForPush( if (wantsNative) { // Raw APNs (iOS) / FCM (Android) device token — the self-hosted daemon or // relay sends via APNs/FCM directly. No EAS projectId needed. + // `DevicePushToken.data` is typed `any` (the ImplicitlySupportedDevicePushToken + // union arm), not `string`, so coerce to guarantee a string at runtime. const device = await Notifications.getDevicePushTokenAsync(); token = typeof device.data === "string" ? device.data : String(device.data); } else {