diff --git a/.changeset/163-install-id-token-rotation.md b/.changeset/163-install-id-token-rotation.md new file mode 100644 index 0000000..472f22f --- /dev/null +++ b/.changeset/163-install-id-token-rotation.md @@ -0,0 +1,30 @@ +--- +"mobile": patch +--- + +Clause-②: no +Metadata-protocol: none + +The bridge exposes a stable install id and tells the Console when the push +token rotates, so the Console can keep one device row per install in the +server's device registry. + +- `window.ObjectStackNative.installId`: a random UUID v4 generated on first + launch and kept in SecureStore (`AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY`), so + it survives the WebView's storage being cleared. It is not a hardware or + advertising id. Only pages on the configured server's origin see it; any + other main-frame page, such as a trusted sign-in host, reads `null`. It is + also `null` when secure storage is unavailable at page start. The Console + sends it as `deviceId`. +- New capability and window event `pushTokenChanged`: when APNs / FCM report a + token, the shell dispatches `objectstack-native:pushTokenChanged` with + `detail: { token, vendor }`. Only pages on the configured server's origin + receive it. A token reported before the page's `ready()` is held, and the + latest one is delivered at `ready()`. +- Notification taps also read `userInfo.data.route` / `.url` on iOS, as a + fourth candidate after `content.data`, the top-level keys and `body`. +- Docs: the `ObjectStackShell/` user-agent marker is now a + documented contract, and the Console registers no Service Worker inside + the shell. + +Additive: no bridge protocol version bump. diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index c91d104..8b2c687 100644 --- a/__tests__/components/ConsoleWebView.test.tsx +++ b/__tests__/components/ConsoleWebView.test.tsx @@ -42,6 +42,21 @@ jest.mock("~/hooks/useNetworkStatus", () => ({ useNetworkStatus: () => ({ isConnected: mockIsConnected }), })); +jest.mock("~/lib/install-id", () => ({ + getInstallId: () => "3b241101-e2bb-4255-8caf-4136c566a962", +})); + +// The OS token listener, captured so a test can report a rotated token. +const mockTokenListeners: Array<(token: unknown) => void> = []; +const mockTokenRemove = jest.fn(); +jest.mock("expo-notifications", () => ({ + ...jest.requireActual("../../__mocks__/expo-notifications"), + addPushTokenListener: jest.fn((listener: (token: unknown) => void) => { + mockTokenListeners.push(listener); + return { remove: mockTokenRemove }; + }), +})); + const SERVER = "https://acme.example.com"; function renderConsole(overrides: Partial = {}) { @@ -103,6 +118,124 @@ describe("ConsoleWebView", () => { expect(mockWebView.props.applicationNameForUserAgent).toMatch(/^ObjectStackShell\//); }); + it("announces the install id and the pushTokenChanged capability", () => { + renderConsole(); + const window: Record = { location: { origin: SERVER } }; + const document = { documentElement: null, addEventListener: () => undefined }; + new Function("window", "document", mockWebView.props.injectedJavaScriptBeforeContentLoaded)( + window, + document + ); + expect(window.ObjectStackNative.installId).toBe("3b241101-e2bb-4255-8caf-4136c566a962"); + expect(window.ObjectStackNative.has("pushTokenChanged")).toBe(true); + expect(window.ObjectStackNative.has("getPushToken")).toBe(true); + }); + + describe("pushTokenChanged", () => { + beforeEach(() => { + mockTokenListeners.length = 0; + mockTokenRemove.mockReset(); + }); + + const rotate = (token: unknown) => act(() => mockTokenListeners[mockTokenListeners.length - 1](token)); + + /** Run an injected script on a page of `origin`; return the events it dispatched. */ + function runOnPage(script: string, origin = SERVER) { + const target = new EventTarget(); + const seen: unknown[] = []; + target.addEventListener("objectstack-native:pushTokenChanged", (event) => + seen.push((event as CustomEvent).detail) + ); + new Function("window", script)({ + CustomEvent, + dispatchEvent: target.dispatchEvent.bind(target), + location: { origin }, + }); + return seen; + } + + /** The Console posts `ready` (its shell-event listeners are mounted). */ + async function postReady() { + await act(async () => { + mockWebView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: "event", name: "ready" }), + url: `${SERVER}/_console/home`, + }, + }); + }); + } + + /** The events every injected pushTokenChanged script dispatched on a server page. */ + const delivered = () => + mockWebView.injectJavaScript.mock.calls + .map(([script]) => script as string) + .filter((script) => script.includes("objectstack-native:pushTokenChanged")) + .flatMap((script) => runOnPage(script)); + + it("delivers a rotated token to a ready page as a window event", async () => { + renderConsole(); + finishLoad(); + await postReady(); + rotate({ type: "android", data: "fcm-2" }); + expect(mockWebView.injectJavaScript).toHaveBeenCalledTimes(1); + const script = mockWebView.injectJavaScript.mock.calls[0][0]; + expect(runOnPage(script)).toEqual([{ token: "fcm-2", vendor: "fcm" }]); + // Never to whatever else is in the main frame (a trusted sign-in host). + expect(runOnPage(script, "https://login.idp.example")).toEqual([]); + }); + + it("holds a token reported before ready — after load end too — and delivers the latest at ready", async () => { + renderConsole(); + rotate({ type: "ios", data: "apns-1" }); // cold start: no page yet + finishLoad(); // document loaded, Console listeners not mounted yet + rotate({ type: "ios", data: "apns-2" }); + expect(delivered()).toEqual([]); + await postReady(); + expect(delivered()).toEqual([{ token: "apns-2", vendor: "apns" }]); + // Delivered once: a second ready has nothing held. + await postReady(); + expect(delivered()).toHaveLength(1); + }); + + it("holds a token reported while the failure page is up, for the recovered page", async () => { + renderConsole(); + failLoad(); + rotate({ type: "android", data: "fcm-3" }); + expect(delivered()).toEqual([]); + fireEvent.press(screen.getByTestId("shell-retry")); // a re-created WebView + act(() => mockWebView.props.onLoadStart({ nativeEvent: {} })); + finishLoad(); + await postReady(); + expect(delivered()).toEqual([{ token: "fcm-3", vendor: "fcm" }]); + }); + + it("re-arms the hold when a new document starts loading", async () => { + renderConsole(); + finishLoad(); + await postReady(); + act(() => mockWebView.props.onLoadStart({ nativeEvent: {} })); // a reload + rotate({ type: "ios", data: "apns-4" }); + expect(delivered()).toEqual([]); + await postReady(); + expect(delivered()).toEqual([{ token: "apns-4", vendor: "apns" }]); + }); + + it("ignores a report that is not a native token", async () => { + renderConsole(); + finishLoad(); + await postReady(); + rotate({ type: "ios", data: "" }); + expect(mockWebView.injectJavaScript).not.toHaveBeenCalled(); + }); + + it("stops listening when unmounted", () => { + renderConsole(); + screen.unmount(); + expect(mockTokenRemove).toHaveBeenCalledTimes(1); + }); + }); + it("shows a loading overlay until the page finishes loading", () => { renderConsole(); expect(screen.getByText("Loading…")).toBeTruthy(); diff --git a/__tests__/hooks/usePushRouting.test.ts b/__tests__/hooks/usePushRouting.test.ts index 22b9dd7..30d32ee 100644 --- a/__tests__/hooks/usePushRouting.test.ts +++ b/__tests__/hooks/usePushRouting.test.ts @@ -1,6 +1,16 @@ import { renderHook, waitFor } from "@testing-library/react-native"; import * as Notifications from "expo-notifications"; -import { usePushRouting, _resetRoutedNotifications } from "~/hooks/usePushRouting"; +import { + usePushRouting, + usePushTokenChanges, + _resetRoutedNotifications, +} from "~/hooks/usePushRouting"; + +// The shared manual mock predates token events; add the one listener used here. +jest.mock("expo-notifications", () => ({ + ...jest.requireActual("../../__mocks__/expo-notifications"), + addPushTokenListener: jest.fn(() => ({ remove: jest.fn() })), +})); const mockListen = Notifications.addNotificationResponseReceivedListener as jest.Mock; const mockLast = Notifications.getLastNotificationResponseAsync as jest.Mock; @@ -63,3 +73,34 @@ describe("usePushRouting", () => { expect(remove).toHaveBeenCalled(); }); }); + +describe("usePushTokenChanges", () => { + const mockAddToken = Notifications.addPushTokenListener as jest.Mock; + + beforeEach(() => { + mockAddToken.mockReset(); + mockAddToken.mockReturnValue({ remove: jest.fn() }); + }); + + it("reports a rotated token to the latest callback, subscribing once", () => { + const first = jest.fn(); + const second = jest.fn(); + const { rerender } = renderHook(({ cb }) => usePushTokenChanges(cb), { + initialProps: { cb: first }, + }); + rerender({ cb: second }); + expect(mockAddToken).toHaveBeenCalledTimes(1); + + mockAddToken.mock.calls[0][0]({ type: "ios", data: "apns-2" }); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith({ token: "apns-2", vendor: "apns" }); + }); + + it("unsubscribes on unmount", () => { + const remove = jest.fn(); + mockAddToken.mockReturnValue({ remove }); + const { unmount } = renderHook(() => usePushTokenChanges(jest.fn())); + unmount(); + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/lib/install-id.test.ts b/__tests__/lib/install-id.test.ts new file mode 100644 index 0000000..9fd6d03 --- /dev/null +++ b/__tests__/lib/install-id.test.ts @@ -0,0 +1,122 @@ +import { uuid } from "expo-modules-core"; +import { + INSTALL_ID_KEY, + _resetInstallIdCache, + getInstallId, + isInstallId, +} from "~/lib/install-id"; + +const mockStore = new Map(); +const mockGetItem = jest.fn( + (key: string, _options?: unknown): string | null => mockStore.get(key) ?? null +); +const mockSetItem = jest.fn((key: string, value: string, _options?: unknown): void => { + mockStore.set(key, value); +}); + +// The shared setup mocks only the async API; the install id is read +// synchronously (the bridge bootstrap is built synchronously). +jest.mock("expo-secure-store", () => ({ + AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY", + getItem: (key: string, options: unknown) => mockGetItem(key, options), + setItem: (key: string, value: string, options: unknown) => mockSetItem(key, value, options), +})); + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +beforeEach(() => { + mockStore.clear(); + mockGetItem.mockClear(); + mockSetItem.mockClear(); + _resetInstallIdCache(); +}); + +describe("getInstallId", () => { + it("generates a random UUID v4 on first use and keeps it in SecureStore", () => { + const id = getInstallId(); + expect(id).toMatch(UUID_V4); + expect(mockStore.get(INSTALL_ID_KEY)).toBe(id); + // Kept out of backups restored onto another device, readable after the + // first unlock (a notification can start the app in the background). + expect(mockSetItem).toHaveBeenCalledWith(INSTALL_ID_KEY, id, { + keychainAccessible: "AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY", + }); + }); + + it("is stable across calls", () => { + const first = getInstallId(); + expect(getInstallId()).toBe(first); + expect(mockSetItem).toHaveBeenCalledTimes(1); + }); + + it("is stable across launches: a stored id is read back, not replaced", () => { + const first = getInstallId(); + _resetInstallIdCache(); // a new process; SecureStore keeps the value + expect(getInstallId()).toBe(first); + expect(mockSetItem).toHaveBeenCalledTimes(1); + }); + + it("does not depend on WebView storage: only SecureStore is read", () => { + const first = getInstallId(); + _resetInstallIdCache(); + mockGetItem.mockClear(); + expect(getInstallId()).toBe(first); + expect(mockGetItem).toHaveBeenCalledWith(INSTALL_ID_KEY, expect.anything()); + }); + + it("is regenerated when missing", () => { + const first = getInstallId(); + mockStore.delete(INSTALL_ID_KEY); // Android "clear storage", reinstall + _resetInstallIdCache(); + const second = getInstallId(); + expect(second).toMatch(UUID_V4); + expect(second).not.toBe(first); + expect(mockStore.get(INSTALL_ID_KEY)).toBe(second); + }); + + it("replaces a stored value that is not an install id", () => { + mockStore.set(INSTALL_ID_KEY, "not-an-id"); + const id = getInstallId(); + expect(id).toMatch(UUID_V4); + expect(mockStore.get(INSTALL_ID_KEY)).toBe(id); + }); + + it("answers null, and mints nothing, when SecureStore cannot be read", () => { + mockGetItem.mockImplementationOnce(() => { + throw new Error("keychain locked"); + }); + expect(getInstallId()).toBeNull(); + expect(mockSetItem).not.toHaveBeenCalled(); + // Tried again on the next call. + expect(getInstallId()).toMatch(UUID_V4); + }); + + it("never hands out an id it could not store", () => { + mockSetItem.mockImplementationOnce(() => { + throw new Error("write refused"); + }); + expect(getInstallId()).toBeNull(); + const id = getInstallId(); + expect(id).toMatch(UUID_V4); + expect(mockStore.get(INSTALL_ID_KEY)).toBe(id); + }); + + it("answers null when the platform has no UUID generator", () => { + const spy = jest.spyOn(uuid, "v4").mockImplementationOnce(() => { + throw new Error("Native UUID version 4 generator implementation wasn't found"); + }); + expect(getInstallId()).toBeNull(); + expect(mockSetItem).not.toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe("isInstallId", () => { + it("accepts only a lower-case UUID v4", () => { + expect(isInstallId("3b241101-e2bb-4255-8caf-4136c566a962")).toBe(true); + expect(isInstallId("3B241101-E2BB-4255-8CAF-4136C566A962")).toBe(false); + expect(isInstallId("3b241101-e2bb-1255-8caf-4136c566a962")).toBe(false); + expect(isInstallId(null)).toBe(false); + expect(isInstallId("")).toBe(false); + }); +}); diff --git a/__tests__/lib/push-notifications.test.ts b/__tests__/lib/push-notifications.test.ts index 6f42cae..ba4cc1d 100644 --- a/__tests__/lib/push-notifications.test.ts +++ b/__tests__/lib/push-notifications.test.ts @@ -1,11 +1,19 @@ import { acquireDeviceToken, + addPushTokenChangeListener, ensurePermission, + pushTokenFromDevice, routeFromPushData, routeFromResponse, ANDROID_DEFAULT_CHANNEL_ID, } from "~/lib/push-notifications"; import * as Notifications from "expo-notifications"; + +// The shared manual mock predates token events; add the one listener used here. +jest.mock("expo-notifications", () => ({ + ...jest.requireActual("../../__mocks__/expo-notifications"), + addPushTokenListener: jest.fn(() => ({ remove: jest.fn() })), +})); // The mock is `__esModule: true`, so this namespace is the same object the lib // imports (`import * as Device`) — mutating `Device.isDevice` here is observed // by the code under test. (`jest.requireMock` returns a *different* object.) @@ -113,6 +121,116 @@ describe("routeFromResponse", () => { const response = iosResponse({ aps: {}, route: "/%2e%2e/api/v1/me" }); expect(routeFromResponse(response, SERVER)).toBeNull(); }); + + it("falls back to userInfo.data.route (iOS, keys nested under data)", () => { + const response = iosResponse({ aps: {}, data: { route: "/apps/crm/lead/4" } }); + expect(routeFromResponse(response, SERVER)).toBe("/apps/crm/lead/4"); + }); + + it("reads userInfo.data only after the other three candidates", () => { + const data = { route: "/from/data" }; + // Each of the three earlier candidates wins over userInfo.data. + expect( + routeFromResponse( + { + notification: { + request: { + content: { data: { route: "/from/content" } }, + trigger: { type: "push", payload: { aps: {}, data } }, + }, + }, + } as unknown as Notifications.NotificationResponse, + SERVER + ) + ).toBe("/from/content"); + expect(routeFromResponse(iosResponse({ aps: {}, route: "/from/top", data }), SERVER)).toBe( + "/from/top" + ); + // No content.data and no top-level route: `body`, the third, still wins. + expect( + routeFromResponse( + { + notification: { + request: { + content: { data: null }, + trigger: { type: "push", payload: { aps: {}, body: { route: "/from/body" }, data } }, + }, + }, + } as unknown as Notifications.NotificationResponse, + SERVER + ) + ).toBe("/from/body"); + }); + + it("skips an invalid earlier candidate and still reads userInfo.data", () => { + const response = iosResponse({ + aps: {}, + route: "/%2e%2e/api/v1/me", + data: { route: "/apps/crm/lead/5" }, + }); + expect(routeFromResponse(response, SERVER)).toBe("/apps/crm/lead/5"); + }); + + it("refuses an escaping route under userInfo.data", () => { + const response = iosResponse({ aps: {}, data: { route: "//evil.example/x" } }); + expect(routeFromResponse(response, SERVER)).toBeNull(); + }); +}); + +describe("pushTokenFromDevice", () => { + it("maps a native token to the bridge shape", () => { + expect(pushTokenFromDevice({ type: "ios", data: "apns-tok" })).toEqual({ + token: "apns-tok", + vendor: "apns", + }); + expect(pushTokenFromDevice({ type: "android", data: "fcm-tok" })).toEqual({ + token: "fcm-tok", + vendor: "fcm", + }); + }); + + it("refuses anything that is not a native APNs / FCM string", () => { + expect(pushTokenFromDevice(null)).toBeNull(); + expect(pushTokenFromDevice({ type: "ios", data: "" })).toBeNull(); + expect( + pushTokenFromDevice({ type: "web", data: { endpoint: "x" } } as unknown as Notifications.DevicePushToken) + ).toBeNull(); + }); +}); + +describe("addPushTokenChangeListener", () => { + const mockAdd = Notifications.addPushTokenListener as jest.Mock; + + beforeEach(() => { + mockAdd.mockReset(); + }); + + it("reports each rotated native token as { token, vendor }", () => { + const remove = jest.fn(); + mockAdd.mockReturnValue({ remove }); + const seen: unknown[] = []; + const sub = addPushTokenChangeListener((token) => seen.push(token)); + + const osListener = mockAdd.mock.calls[0][0]; + osListener({ type: "android", data: "fcm-1" }); + osListener({ type: "android", data: "fcm-2" }); + osListener({ type: "android", data: "" }); + expect(seen).toEqual([ + { token: "fcm-1", vendor: "fcm" }, + { token: "fcm-2", vendor: "fcm" }, + ]); + + sub.remove(); + expect(remove).toHaveBeenCalledTimes(1); + }); + + it("is a no-op subscription where token events are unavailable", () => { + mockAdd.mockImplementation(() => { + throw new Error("removed from Expo Go"); + }); + const sub = addPushTokenChangeListener(jest.fn()); + expect(() => sub.remove()).not.toThrow(); + }); }); describe("ensurePermission", () => { diff --git a/__tests__/lib/shell/bridge-protocol.test.ts b/__tests__/lib/shell/bridge-protocol.test.ts index d348f47..d5701a4 100644 --- a/__tests__/lib/shell/bridge-protocol.test.ts +++ b/__tests__/lib/shell/bridge-protocol.test.ts @@ -1,4 +1,24 @@ -import { MAX_MESSAGE_LENGTH, parseInbound } from "~/lib/shell/bridge/protocol"; +import { + BRIDGE_CAPABILITIES, + BRIDGE_METHODS, + MAX_MESSAGE_LENGTH, + PUSH_TOKEN_CHANGED_EVENT, + SHELL_EVENTS, + parseInbound, + shellEventType, +} from "~/lib/shell/bridge/protocol"; + +describe("capabilities", () => { + it("announces every method and every shell event", () => { + expect(BRIDGE_CAPABILITIES).toEqual([...BRIDGE_METHODS, ...SHELL_EVENTS]); + expect(BRIDGE_CAPABILITIES).toContain("pushTokenChanged"); + }); + + it("names a shell event's DOM type after its capability", () => { + expect(shellEventType("pushTokenChanged")).toBe(PUSH_TOKEN_CHANGED_EVENT); + expect(PUSH_TOKEN_CHANGED_EVENT).toBe("objectstack-native:pushTokenChanged"); + }); +}); describe("parseInbound", () => { it("parses a call", () => { diff --git a/__tests__/lib/shell/bridge-scripts.test.ts b/__tests__/lib/shell/bridge-scripts.test.ts index 1eb3297..97f50a1 100644 --- a/__tests__/lib/shell/bridge-scripts.test.ts +++ b/__tests__/lib/shell/bridge-scripts.test.ts @@ -1,29 +1,37 @@ import { buildBootstrapScript, buildNavigateScript, + buildPushTokenChangedScript, buildResolveScript, toScriptLiteral, } from "~/lib/shell/bridge/scripts"; import { BRIDGE_PROTOCOL_VERSION, NAVIGATE_EVENT, + PUSH_TOKEN_CHANGED_EVENT, type BridgeHostInfo, } from "~/lib/shell/bridge/protocol"; +const INSTALL_ID = "3b241101-e2bb-4255-8caf-4136c566a962"; + const info: BridgeHostInfo = { protocolVersion: BRIDGE_PROTOCOL_VERSION, platform: "ios", appVersion: "2.0.0", + installId: INSTALL_ID, capabilities: ["haptic", "getPushToken"], safeArea: { top: 47, bottom: 34, left: 0, right: 0 }, }; -/** Run injected page script against a fake `window` / `document`. */ -function makePage() { +const SERVER_URL = "https://acme.example.com/os"; + +/** Run injected page script against a fake `window` / `document` on `origin`. */ +function makePage(origin = "https://acme.example.com") { const posted: unknown[] = []; const cssVars: Record = {}; const listeners: Record void> = {}; const window: Record = { + location: { origin }, ReactNativeWebView: { postMessage: (m: string) => posted.push(JSON.parse(m)) }, }; const document = { @@ -43,7 +51,7 @@ describe("toScriptLiteral", () => { describe("bootstrap script", () => { it("exposes a frozen ObjectStackNative with host info", () => { const page = makePage(); - page.run(buildBootstrapScript(info)); + page.run(buildBootstrapScript(info, SERVER_URL)); const api = page.window.ObjectStackNative; expect(api.version).toBe(BRIDGE_PROTOCOL_VERSION); expect(api.platform).toBe("ios"); @@ -53,9 +61,47 @@ describe("bootstrap script", () => { expect(Object.isFrozen(api.capabilities)).toBe(true); }); + it("exposes the install id, read-only, to a page on the configured server's origin", () => { + const page = makePage(); + page.run(buildBootstrapScript(info, SERVER_URL)); + const api = page.window.ObjectStackNative; + expect(api.installId).toBe(INSTALL_ID); + expect(Object.getOwnPropertyDescriptor(api, "installId")?.writable).toBe(false); + expect(Object.isFrozen(api)).toBe(true); + }); + + it("exposes a null install id to a page on another origin (a trusted sign-in host)", () => { + const page = makePage("https://login.idp.example"); + page.run(buildBootstrapScript(info, SERVER_URL)); + const api = page.window.ObjectStackNative; + expect(api.installId).toBeNull(); + // The rest of host info is unchanged there; the id is not in reach at all. + expect(api.platform).toBe("ios"); + expect(JSON.stringify(api)).not.toContain(INSTALL_ID); + }); + + it("compares the page origin with the default port dropped", () => { + const page = makePage("https://acme.example.com"); + page.run(buildBootstrapScript(info, "https://acme.example.com:443/os")); + expect(page.window.ObjectStackNative.installId).toBe(INSTALL_ID); + }); + + it("exposes a null install id with no usable server URL", () => { + const page = makePage("https://acme.example.com"); + page.run(buildBootstrapScript(info, "not a url")); + expect(page.window.ObjectStackNative.installId).toBeNull(); + expect(buildBootstrapScript(info, "not a url")).not.toContain(INSTALL_ID); + }); + + it("exposes a null install id when the shell has none", () => { + const page = makePage(); + page.run(buildBootstrapScript({ ...info, installId: null }, SERVER_URL)); + expect(page.window.ObjectStackNative.installId).toBeNull(); + }); + it("publishes the safe area as CSS variables", () => { const page = makePage(); - page.run(buildBootstrapScript(info)); + page.run(buildBootstrapScript(info, SERVER_URL)); expect(page.cssVars["--os-native-safe-top"]).toBe("47px"); expect(page.cssVars["--os-native-safe-bottom"]).toBe("34px"); expect(page.listeners.DOMContentLoaded).toBeDefined(); @@ -63,7 +109,7 @@ describe("bootstrap script", () => { it("posts calls and settles them through the resolve global", async () => { const page = makePage(); - page.run(buildBootstrapScript(info)); + page.run(buildBootstrapScript(info, SERVER_URL)); const pending = page.window.ObjectStackNative.haptic("success"); expect(page.posted).toEqual([ { type: "call", id: "c1", method: "haptic", args: { kind: "success" } }, @@ -74,7 +120,7 @@ describe("bootstrap script", () => { it("rejects failed calls with the error code", async () => { const page = makePage(); - page.run(buildBootstrapScript(info)); + page.run(buildBootstrapScript(info, SERVER_URL)); const pending = page.window.ObjectStackNative.navigate("bad"); page.run( buildResolveScript({ id: "c1", ok: false, error: { code: "bad_args", message: "nope" } }) @@ -84,13 +130,13 @@ describe("bootstrap script", () => { it("ignores results for unknown calls", () => { const page = makePage(); - page.run(buildBootstrapScript(info)); + page.run(buildBootstrapScript(info, SERVER_URL)); expect(() => page.run(buildResolveScript({ id: "c99", ok: true, value: 1 }))).not.toThrow(); }); it("posts events", () => { const page = makePage(); - page.run(buildBootstrapScript(info)); + page.run(buildBootstrapScript(info, SERVER_URL)); page.window.ObjectStackNative.ready(); page.window.ObjectStackNative.routeChanged("/apps/crm"); expect(page.posted).toEqual([ @@ -101,9 +147,9 @@ describe("bootstrap script", () => { it("is idempotent across re-injection", () => { const page = makePage(); - page.run(buildBootstrapScript(info)); + page.run(buildBootstrapScript(info, SERVER_URL)); const first = page.window.ObjectStackNative; - page.run(buildBootstrapScript({ ...info, appVersion: "9.9.9" })); + page.run(buildBootstrapScript({ ...info, appVersion: "9.9.9" }, SERVER_URL)); expect(page.window.ObjectStackNative).toBe(first); }); }); @@ -178,3 +224,68 @@ describe("navigate script", () => { expect(page.assign).toHaveBeenCalledWith(`${SERVER}/_console${route}`); }); }); + +describe("pushTokenChanged script", () => { + const SERVER = "https://acme.example.com"; + + /** A fake page window on `origin` with real DOM events. */ + function makeEventPage(origin: string) { + const target = new EventTarget(); + const window = { + CustomEvent, + dispatchEvent: target.dispatchEvent.bind(target), + addEventListener: target.addEventListener.bind(target), + location: { origin }, + }; + const seen: Array<{ detail: unknown; cancelable: boolean }> = []; + window.addEventListener(PUSH_TOKEN_CHANGED_EVENT, (event: Event) => { + seen.push({ detail: (event as CustomEvent).detail, cancelable: event.cancelable }); + }); + const run = (script: string | null) => { + if (script === null) throw new Error("no script"); + return new Function("window", script)(window); + }; + return { seen, run }; + } + + it("dispatches { token, vendor } on a page of the configured server", () => { + const page = makeEventPage(SERVER); + page.run(buildPushTokenChangedScript(`${SERVER}/os`, { token: "fcm-2", vendor: "fcm" })); + expect(PUSH_TOKEN_CHANGED_EVENT).toBe("objectstack-native:pushTokenChanged"); + expect(page.seen).toEqual([{ detail: { token: "fcm-2", vendor: "fcm" }, cancelable: false }]); + }); + + it("delivers nothing to a page on another origin (a trusted sign-in host)", () => { + const page = makeEventPage("https://login.idp.example"); + page.run(buildPushTokenChangedScript(SERVER, { token: "fcm-2", vendor: "fcm" })); + expect(page.seen).toEqual([]); + }); + + it("compares origins with the default port dropped", () => { + const page = makeEventPage(SERVER); + page.run(buildPushTokenChangedScript(`${SERVER}:443`, { token: "t", vendor: "apns" })); + expect(page.seen).toHaveLength(1); + }); + + it("does nothing without CustomEvent support", () => { + expect(() => + new Function("window", buildPushTokenChangedScript(SERVER, { token: "t", vendor: "apns" })!)({ + location: { origin: SERVER }, + }) + ).not.toThrow(); + }); + + it("builds no script for an empty token or an unusable server URL", () => { + expect(buildPushTokenChangedScript(SERVER, { token: "", vendor: "fcm" })).toBeNull(); + expect(buildPushTokenChangedScript("not a url", { token: "t", vendor: "fcm" })).toBeNull(); + }); + + it("splices the token as escaped JSON", () => { + const token = '");alert(1);("\u2028'; + const script = buildPushTokenChangedScript(SERVER, { token, vendor: "apns" })!; + expect(script).not.toContain(""); + const page = makeEventPage(SERVER); + page.run(script); + expect(page.seen).toEqual([{ detail: { token, vendor: "apns" }, cancelable: false }]); + }); +}); diff --git a/components/shell/ConsoleWebView.tsx b/components/shell/ConsoleWebView.tsx index 255458f..8724d3e 100644 --- a/components/shell/ConsoleWebView.tsx +++ b/components/shell/ConsoleWebView.tsx @@ -23,18 +23,22 @@ import { consoleUrl, routeFromConsoleUrl, safeConsoleRoute } from "~/lib/shell/c import { decideNavigation } from "~/lib/shell/navigation-policy"; import type { RouteRule } from "~/lib/shell/route-owner"; import { - BRIDGE_METHODS, + BRIDGE_CAPABILITIES, BRIDGE_PROTOCOL_VERSION, type BridgeHostInfo, + type PushTokenResult, } from "~/lib/shell/bridge/protocol"; import { buildBootstrapScript, buildNavigateScript, + buildPushTokenChangedScript, buildResolveScript, } from "~/lib/shell/bridge/scripts"; import { createBridgeHandler, type BridgeDeps } from "~/lib/shell/bridge/handler"; +import { getInstallId } from "~/lib/install-id"; import { addBreadcrumb, captureMessage } from "~/lib/sentry"; import { useNetworkStatus } from "~/hooks/useNetworkStatus"; +import { usePushTokenChanges } from "~/hooks/usePushRouting"; export interface ConsoleWebViewHandle { /** Move the Console to `route` on the configured server. */ @@ -212,7 +216,8 @@ export const ConsoleWebView = forwardRef buildBootstrapScript(hostInfo), [hostInfo]); + const bootstrap = useMemo( + () => buildBootstrapScript(hostInfo, serverUrl), + [hostInfo, serverUrl] + ); + + // A push token the OS reports (rotation) goes to the page as + // `objectstack-native:pushTokenChanged`. The Console installs its shell + // event listeners in the same commit that posts `ready`, after the + // document's load end, so a token reported before this document's `ready` + // (cold start, a reload, the failure page) is held — the latest one only — + // and delivered at `ready`. A new document start re-arms the hold. + const consoleReadyRef = useRef(false); + const heldPushTokenRef = useRef(null); + const sendPushToken = useCallback( + (token: PushTokenResult) => { + const script = buildPushTokenChangedScript(serverUrl, token); + if (script) webviewRef.current?.injectJavaScript(script); + }, + [serverUrl] + ); + const deliverPushToken = useCallback( + (token: PushTokenResult) => { + if (consoleReadyRef.current) sendPushToken(token); + else heldPushTokenRef.current = token; + }, + [sendPushToken] + ); + usePushTokenChanges(deliverPushToken); + const onConsoleReady = useCallback(() => { + consoleReadyRef.current = true; + const held = heldPushTokenRef.current; + heldPushTokenRef.current = null; + if (held) sendPushToken(held); + }, [sendPushToken]); + const onDocumentStart = useCallback(() => { + consoleReadyRef.current = false; + }, []); const handleMessage = useMemo( () => @@ -232,10 +273,11 @@ export const ConsoleWebView = forwardRef { if (name === "ready") markLoaded(); + if (name === "ready") onConsoleReady(); addBreadcrumb({ category: "bridge", message: name, data: { payload } }); }, }), - [capabilities, serverUrl, navigate, markLoaded] + [capabilities, serverUrl, navigate, markLoaded, onConsoleReady] ); const onMessage = useCallback( @@ -313,6 +355,9 @@ export const ConsoleWebView = forwardRef { // `loading` is still true when a load is superseded by a redirect. // react-native-webview also calls this after `onError`; diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index 22c4c2a..a3ede24 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -111,7 +111,8 @@ interface ObjectStackNative { version: number; // BRIDGE_PROTOCOL_VERSION — bumps only on breaking change platform: "ios" | "android"; appVersion: string; - capabilities: readonly string[]; // feature-detect with has() + installId: string | null; // random per-install id (UUID v4) — see "Install id" + capabilities: readonly string[]; // methods and shell events; feature-detect with has() safeArea: { top: number; bottom: number; left: number; right: number }; has(capability: string): boolean; @@ -131,6 +132,27 @@ interface ObjectStackNative { Safe-area insets are also published as CSS variables: `--os-native-safe-top|bottom|left|right`. +### Install id + +`installId` is a random UUID v4 the shell generates on first launch +(`lib/install-id.ts`) and keeps in SecureStore, outside the WebView, so it +survives the WebView's storage (cookies, localStorage, IndexedDB) being +cleared. The Console sends it as the device registry's `deviceId`, so a +re-registration refreshes the device's row instead of minting a second one. + +- Not a hardware identifier and not an advertising id: it comes from the + platform's CSPRNG and identifies nothing outside this install. +- Android drops it with the app's data (uninstall, "clear storage"; a value + restored from a backup cannot be decrypted and is regenerated). iOS keychain + items can outlive an uninstall, so a reinstall may keep it. It is stored + `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY`: never restored onto another device. +- `null` when secure storage could not be read or written when the WebView + was created (the Console then falls back to a server-minted `deviceId`). +- Read-only, and only for the Console: the bootstrap runs in every main-frame + document (a trusted sign-in host's pages too), so it exposes `installId` + only when `window.location.origin` is the configured server's origin; any + other page reads `null`. Sub-frames never see host info (Known limits). + ### Shell → Console events When a notification tap, an app link or a bridge `navigate()` asks for a @@ -151,6 +173,33 @@ not one its router knows), the shell falls back to route has passed `safeConsoleRoute` before the event is dispatched (`buildNavigateScript` in `bridge/scripts.ts`). +When the OS reports a push token for the app — APNs / FCM rotated it — the +shell dispatches, if the page announces the `pushTokenChanged` capability: + +```ts +new CustomEvent("objectstack-native:pushTokenChanged", { + detail: { token: "…", vendor: "apns" }, // same shape as getPushToken() +}); +``` + +The Console re-registers `detail` under the signed-in session (with +`deviceId = installId`). Rules: + +- Delivered only to a page on the configured server's origin, the same pages + `getPushToken()` answers; another page in the main frame (a trusted sign-in + host) gets nothing. +- Delivered after the page's `ready()`, when the Console's listeners are + mounted. A token reported earlier — cold start, a reload, the failure page — + is held (the latest only) and delivered at `ready()`. +- It can repeat an unchanged token: iOS reports every completed registration, + including the one `getPushToken()` triggers, and Android replays the last + known token when the shell starts listening. Registration is idempotent, so + treat every event as "register this token". +- Not cancelable; the shell does nothing else with it. + +Shell events are named `objectstack-native:` +(`SHELL_EVENTS` in `bridge/protocol.ts`). + ### Wire format Calls are posted as @@ -179,6 +228,47 @@ same name), handle it in `bridge/handler.ts`, expose it in the bootstrap (`bridge/scripts.ts`), and document it here. That is additive — no protocol version bump. +Adding a shell → Console event: add its name to `SHELL_EVENTS` (it becomes a +capability of the same name and the DOM event `objectstack-native:`), +build its dispatch script in `bridge/scripts.ts` (origin-checked when it +carries data only the Console should see), deliver it from `ConsoleWebView`, +and document it here. Also additive. + +### User-agent marker + +The WebView appends `ObjectStackShell/` to its user agent +(`applicationNameForUserAgent` in `ConsoleWebView.tsx`). This is a contract: +the token stays `ObjectStackShell` whatever the app is called, and the test +in `__tests__/components/ConsoleWebView.test.tsx` pins it. + +- It is readable synchronously from the document's first script + (`navigator.userAgent`) and by the server on every request, so it answers + "am I in the shell?" before the bridge exists — Android may inject the + bridge after the page's first scripts (Known limits). +- It says nothing about capabilities: detect those with + `ObjectStackNative.has()`. +- Never a security signal: any client can send it. + +### Service Workers: none inside the shell + +Rule: the Console registers no Service Worker inside the shell, and +unregisters one it registered earlier in the same WebView. Why: + +- **Android WebView** supports Service Workers. A worker that answers the + document from its cache while offline means the WebView's `onError` never + fires, so the shell's offline page (`OfflineFallback`) never shows and its + retry-on-reconnect never runs: the shell's failure handling is masked. +- **iOS WKWebView** exposes no `navigator.serviceWorker` to an app without + App-Bound Domains, so a worker would only ever exist on one platform. +- Offline and push are the shell's: push arrives through APNs / FCM natively, + not through a worker's `push` handler. + +The Console's gate (objectstack-ai/objectstack#29326, in review) reads +`window.ObjectStackNative` after boot. Because +Android can inject the bridge late, the first load can still register a worker +that the next load removes. Reading the user-agent marker as well closes that +window, since it is there from the first byte. + ## Console-side integration (framework repository) The Console needs, in `@object-ui/mobile`: @@ -191,7 +281,11 @@ The Console needs, in `@object-ui/mobile`: `event.preventDefault()` (otherwise the shell reloads the page at the route); - register the push token after sign-in (`getPushToken()` → the server's device - registry) and unregister on sign-out. + registry, with `deviceId = installId` when it is a string) and unregister on + sign-out; +- listen for `objectstack-native:pushTokenChanged` when + `has("pushTokenChanged")` and re-register `event.detail` while signed in; +- register no Service Worker inside the shell (see above). Until then the shell works without it: the loading cover lifts on the WebView's own load event and the shell pads the WebView with the safe area itself. @@ -226,5 +320,13 @@ See [PUSH-NOTIFICATIONS.md](./PUSH-NOTIFICATIONS.md). - **Old Android WebViews cannot tell an iframe's bridge message from the page's.** Without `WebMessageListener` support, the message URL is the main frame's, so the origin check sees the Console's origin for any frame. +- **Host info reaches main-frame documents only.** The bootstrap is injected + into the main frame only: iOS adds it as a main-frame-only user script, and + Android evaluates it in the main frame from `onPageStarted` + (react-native-webview 13.16 supports nothing else there). No bridge method + returns host info, and a cross-origin sub-frame cannot read the main frame's + `window`. A trusted host's page in the main frame gets the non-identifying + fields (`platform`, `appVersion`, `capabilities`, `safeArea`), and `null` for + `installId`. - **Downloads and file inputs** rely on the WebView defaults until the Phase 4 bridge methods land. diff --git a/docs/PUSH-NOTIFICATIONS.md b/docs/PUSH-NOTIFICATIONS.md index 82382e4..0ae0736 100644 --- a/docs/PUSH-NOTIFICATIONS.md +++ b/docs/PUSH-NOTIFICATIONS.md @@ -16,6 +16,14 @@ server ──APNs / FCM──▶ device ──tap──▶ shell (hooks/usePushR - The Console asks for the token over the bridge once the user is signed in and registers it under its own session. The OS permission prompt therefore appears when the Console decides, and the shell needs no API credential. +- The Console registers with `deviceId = installId` (the bridge's host info, + a random id the shell keeps in SecureStore), so clearing the WebView's + storage does not create a second device row on the server. +- When APNs / FCM rotate the token, the shell dispatches + `objectstack-native:pushTokenChanged` (`detail: { token, vendor }`, the + `pushTokenChanged` capability) and the Console re-registers. The shell only + listens (`Notifications.addPushTokenListener`); it never asks for a token + on its own. Delivery rules: [HYBRID-SHELL.md](./HYBRID-SHELL.md#shell--console-events). - A notification names where a tap goes with one of two keys (see the payload contract below): - `route`: a Console route, e.g. `"/apps/crm/opportunity/123"` (preferred); @@ -31,7 +39,9 @@ server ──APNs / FCM──▶ device ──tap──▶ shell (hooks/usePushR `route` / `url` are custom keys, delivered differently by each transport. `routeFromResponse` (`lib/push-notifications.ts`) reads, in order, the notification's `content.data`, then the iOS `trigger.payload` (the full APNs -`userInfo`) and its `body` dictionary, and uses the first valid one. +`userInfo`), its `body` dictionary, and last its `data` dictionary, and uses +the first valid one. `userInfo.data` is off-contract (never put the key under +`data` on iOS); it is read only as a fallback, after the other three. **APNs (iOS)** — put the key at the top level of the payload, beside `aps`: diff --git a/hooks/usePushRouting.ts b/hooks/usePushRouting.ts index ff242df..88add89 100644 --- a/hooks/usePushRouting.ts +++ b/hooks/usePushRouting.ts @@ -7,11 +7,17 @@ * * Token acquisition is NOT done here: the Console asks for the token over the * bridge (`getPushToken`) once the user is signed in, so the OS permission - * prompt appears at a moment the Console chooses. + * prompt appears at a moment the Console chooses. `usePushTokenChanges` only + * listens for tokens the OS reports (rotation) — it never asks for one. */ import { useEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; -import { configureForegroundHandler, routeFromResponse } from "~/lib/push-notifications"; +import { + addPushTokenChangeListener, + configureForegroundHandler, + routeFromResponse, +} from "~/lib/push-notifications"; +import type { PushTokenResult } from "~/lib/shell/bridge/protocol"; const routed = new Set(); @@ -54,3 +60,20 @@ export function usePushRouting(serverUrl: string | null, onRoute: (route: string return () => sub.remove(); }, [serverUrl]); } + +/** + * Calls `onToken` with each native push token the OS reports while mounted + * (see `addPushTokenChangeListener`). Subscribes once; the latest `onToken` + * is used without re-subscribing. + */ +export function usePushTokenChanges(onToken: (token: PushTokenResult) => void): void { + const onTokenRef = useRef(onToken); + useEffect(() => { + onTokenRef.current = onToken; + }, [onToken]); + + useEffect(() => { + const sub = addPushTokenChangeListener((token) => onTokenRef.current(token)); + return () => sub.remove(); + }, []); +} diff --git a/lib/install-id.ts b/lib/install-id.ts new file mode 100644 index 0000000..1a87b93 --- /dev/null +++ b/lib/install-id.ts @@ -0,0 +1,77 @@ +/** + * Install id — a random identifier for this install of the app. + * + * The Console sends it as the push device registry's `deviceId`, so a device + * keeps one registry row even after the WebView's storage (cookies, + * localStorage, IndexedDB) is cleared: the id lives in SecureStore, outside + * the WebView. It is a UUID v4 from the platform's CSPRNG (expo-modules-core: + * `UUID()` on iOS, `UUID.randomUUID()` on Android) — never a hardware id and + * never an advertising id. + * + * Lifetime: generated on first read, then stable. Android drops it with the + * app's data (uninstall, "clear storage"; a value restored from a backup + * cannot be decrypted and reads as missing). iOS keychain items can outlive an + * uninstall, so a reinstall may keep the id. `THIS_DEVICE_ONLY` keeps it out + * of backups restored onto another device. + * + * Read synchronously, because the bridge bootstrap is built synchronously when + * the WebView mounts. A read or write that fails answers `null` and is tried + * again on the next call; an id that could not be stored is never handed out, + * since the next launch would mint another one. + */ +import * as SecureStore from "expo-secure-store"; +import { uuid } from "expo-modules-core"; + +export const INSTALL_ID_KEY = "objectstack_install_id"; + +const STORE_OPTIONS: SecureStore.SecureStoreOptions = { + // Readable when the app starts in the background (a notification) on a + // device unlocked once since boot; never restored onto another device. + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, +}; + +const INSTALL_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +let cached: string | null = null; + +/** Whether `value` is an install id this module would have written. */ +export function isInstallId(value: unknown): value is string { + return typeof value === "string" && INSTALL_ID_RE.test(value); +} + +/** + * This install's id, generated and stored on first use; `null` when secure + * storage cannot be read or written right now. + */ +export function getInstallId(): string | null { + if (cached !== null) return cached; + + let stored: string | null; + try { + stored = SecureStore.getItem(INSTALL_ID_KEY, STORE_OPTIONS); + } catch { + // Unreadable (e.g. the keychain before the first unlock): do not mint a + // replacement over a value that may still be there. + return null; + } + if (isInstallId(stored)) { + cached = stored; + return stored; + } + + let fresh: string; + try { + fresh = uuid.v4().toLowerCase(); + if (!isInstallId(fresh)) return null; + SecureStore.setItem(INSTALL_ID_KEY, fresh, STORE_OPTIONS); + } catch { + return null; + } + cached = fresh; + return fresh; +} + +/** Test hook: forget the id read in this process (storage is untouched). */ +export function _resetInstallIdCache(): void { + cached = null; +} diff --git a/lib/push-notifications.ts b/lib/push-notifications.ts index edee6ac..8c0a6f2 100644 --- a/lib/push-notifications.ts +++ b/lib/push-notifications.ts @@ -8,6 +8,9 @@ * - hand that token to the Console over the bridge (`getPushToken`). The * Console registers it with the server under the signed-in session, so the * shell never holds an API credential; + * - tell the Console when the OS reports a token (rotation), so it can + * register the new one (`addPushTokenChangeListener`, delivered as the + * `objectstack-native:pushTokenChanged` window event); * - turn a tapped notification into a Console route. * * No React here; `hooks/usePushRouting` owns the listener lifecycle. @@ -105,6 +108,46 @@ export async function acquireDeviceToken(): Promise { } } +/** + * A device token as the bridge hands it to the Console, or `null` for a token + * that is not a native APNs / FCM string (web, Expo push-service, empty). + */ +export function pushTokenFromDevice( + token: Notifications.DevicePushToken | null | undefined +): PushTokenResult | null { + if (!token || typeof token.data !== "string" || token.data.length === 0) return null; + if (token.type === "ios") return { token: token.data, vendor: "apns" }; + if (token.type === "android") return { token: token.data, vendor: "fcm" }; + return null; +} + +export interface PushTokenSubscription { + remove(): void; +} + +/** + * Call `listener` whenever the OS reports a native push token for this app: + * when APNs / FCM rotate it, and possibly again with an unchanged token (iOS + * reports every completed registration, including the one `getPushToken` + * triggers; Android replays the last known token to a new listener). + * + * Never throws: where token events are unavailable (expo-notifications refuses + * remote push in Expo Go on Android) the subscription is a no-op. + */ +export function addPushTokenChangeListener( + listener: (token: PushTokenResult) => void +): PushTokenSubscription { + try { + const sub = Notifications.addPushTokenListener((devicePushToken) => { + const token = pushTokenFromDevice(devicePushToken); + if (token) listener(token); + }); + return { remove: () => sub.remove() }; + } catch { + return { remove: () => undefined }; + } +} + /** * The Console route a notification payload asks to open, or `null`. * Prefers `route`; otherwise accepts a `url` that points into this server's @@ -143,6 +186,9 @@ function asPayload(value: unknown): PushDataPayload | null { * `trigger.payload`, the full `userInfo`. * * So `content.data` is read first, then `trigger.payload` and its `body`. + * Last, `trigger.payload.data`: a sender that nests the keys under `data` on + * iOS (the FCM habit) is off-contract, so that dictionary is read only after + * the other three. * See docs/PUSH-NOTIFICATIONS.md for the payload contract. */ export function routeFromResponse( @@ -153,7 +199,12 @@ export function routeFromResponse( if (!request) return null; const trigger = request.trigger as { payload?: unknown } | null | undefined; const userInfo = asPayload(trigger?.payload); - const candidates = [asPayload(request.content?.data), userInfo, asPayload(userInfo?.body)]; + const candidates = [ + asPayload(request.content?.data), + userInfo, + asPayload(userInfo?.body), + asPayload(userInfo?.data), + ]; for (const data of candidates) { const route = routeFromPushData(data, serverUrl); if (route) return route; diff --git a/lib/shell/bridge/protocol.ts b/lib/shell/bridge/protocol.ts index 07fe923..1253d81 100644 --- a/lib/shell/bridge/protocol.ts +++ b/lib/shell/bridge/protocol.ts @@ -28,6 +28,27 @@ export const BRIDGE_RESOLVE_GLOBAL = "__objectstackNativeResolve"; */ export const NAVIGATE_EVENT = "objectstack-native:navigate"; +/** + * Shell → Console events, each a DOM event `objectstack-native:` on + * `window` and announced as a capability of the same name, so the Console + * feature-detects with `has(name)` before it listens. + */ +export const SHELL_EVENTS = ["pushTokenChanged"] as const; +export type ShellEventName = (typeof SHELL_EVENTS)[number]; + +/** The DOM event type the shell dispatches for a {@link ShellEventName}. */ +export function shellEventType(name: ShellEventName): string { + return `objectstack-native:${name}`; +} + +/** + * DOM event the shell dispatches on `window` when the OS reports a push token + * for this install (`detail: { token, vendor }`, not cancelable): on rotation, + * and possibly again with an unchanged token. The Console re-registers the + * token under the signed-in session; registration is idempotent. + */ +export const PUSH_TOKEN_CHANGED_EVENT = shellEventType("pushTokenChanged"); + /** Largest message the shell will parse (bytes of JSON text). */ export const MAX_MESSAGE_LENGTH = 64 * 1024; @@ -41,6 +62,9 @@ export const BRIDGE_METHODS = [ ] as const; export type BridgeMethod = (typeof BRIDGE_METHODS)[number]; +/** Everything the shell announces in `capabilities`: its methods and its events. */ +export const BRIDGE_CAPABILITIES: readonly string[] = [...BRIDGE_METHODS, ...SHELL_EVENTS]; + /** Fire-and-forget notifications from the Console. */ export const BRIDGE_EVENTS = ["ready", "routeChanged"] as const; export type BridgeEventName = (typeof BRIDGE_EVENTS)[number]; @@ -89,6 +113,13 @@ export interface BridgeHostInfo { protocolVersion: number; platform: "ios" | "android"; appVersion: string; + /** + * A random id for this install (UUID v4), kept in SecureStore so it survives + * the WebView's storage being cleared. Not a hardware or advertising id. + * `null` when secure storage could not be read or written at page start. + * See `lib/install-id.ts`. + */ + installId: string | null; capabilities: readonly string[]; safeArea: { top: number; bottom: number; left: number; right: number }; } diff --git a/lib/shell/bridge/scripts.ts b/lib/shell/bridge/scripts.ts index a46dc74..c429662 100644 --- a/lib/shell/bridge/scripts.ts +++ b/lib/shell/bridge/scripts.ts @@ -5,13 +5,15 @@ * The bootstrap is written as plain ES5 so it runs before the Console bundle on * every WebView engine the shell supports. */ -import { consoleUrl, safeConsoleRoute } from "../console-url"; +import { consoleUrl, originOf, safeConsoleRoute } from "../console-url"; import { BRIDGE_GLOBAL, BRIDGE_RESOLVE_GLOBAL, NAVIGATE_EVENT, + PUSH_TOKEN_CHANGED_EVENT, type BridgeHostInfo, type BridgeResult, + type PushTokenResult, } from "./protocol"; /** @@ -26,11 +28,24 @@ export function toScriptLiteral(value: unknown): string { .replace(/\u2029/g, "\\u2029"); } -/** Bootstrap for `injectedJavaScriptBeforeContentLoaded`. */ -export function buildBootstrapScript(info: BridgeHostInfo): string { +/** + * Bootstrap for `injectedJavaScriptBeforeContentLoaded`. + * + * Injected into every main-frame document, which includes a trusted sign-in + * host's pages. So `installId`, like every datum only the Console should see, + * is exposed only when the document is on `serverUrl`'s origin; any other page + * reads `null`. + */ +export function buildBootstrapScript(info: BridgeHostInfo, serverUrl: string): string { + const { installId, ...shared } = info; + const serverOrigin = originOf(serverUrl); + const gatedInstallId = + serverOrigin !== null && typeof installId === "string" ? installId : null; return `(function () { if (window.${BRIDGE_GLOBAL}) return; - var info = ${toScriptLiteral(info)}; + var info = ${toScriptLiteral(shared)}; + var onServer = !!window.location && window.location.origin === ${toScriptLiteral(serverOrigin)}; + var installId = onServer ? ${toScriptLiteral(gatedInstallId)} : null; var pending = {}; var seq = 0; function post(msg) { @@ -48,6 +63,7 @@ export function buildBootstrapScript(info: BridgeHostInfo): string { version: info.protocolVersion, platform: info.platform, appVersion: info.appVersion, + installId: installId, capabilities: info.capabilities.slice(), safeArea: info.safeArea, has: function (capability) { return info.capabilities.indexOf(capability) !== -1; }, @@ -128,3 +144,30 @@ export function buildNavigateScript(serverUrl: string, route: string): string | })(); true;`; } + +/** + * Snippet that dispatches `objectstack-native:pushTokenChanged` + * (`detail: { token, vendor }`) on `window`, or `null` when there is no token + * or no server origin to deliver to. + * + * The token goes only to a page on the configured server's origin — the same + * pages `getPushToken` answers. Whatever else is in the main frame when the OS + * reports the token (a trusted identity provider's sign-in page) gets nothing. + */ +export function buildPushTokenChangedScript( + serverUrl: string, + token: PushTokenResult +): string | null { + const origin = originOf(serverUrl); + if (origin === null || typeof token.token !== "string" || token.token.length === 0) { + return null; + } + return `(function () { + if (!window.location || window.location.origin !== ${toScriptLiteral(origin)}) return; + if (typeof window.CustomEvent !== "function" || typeof window.dispatchEvent !== "function") return; + window.dispatchEvent(new window.CustomEvent(${toScriptLiteral(PUSH_TOKEN_CHANGED_EVENT)}, { + detail: ${toScriptLiteral({ token: token.token, vendor: token.vendor })} + })); +})(); +true;`; +}