diff --git a/.changeset/29042-native-push-registration.md b/.changeset/29042-native-push-registration.md
new file mode 100644
index 00000000000..0fe03e7ebe8
--- /dev/null
+++ b/.changeset/29042-native-push-registration.md
@@ -0,0 +1,14 @@
+---
+'@object-ui/console': patch
+'@object-ui/mobile': patch
+'@object-ui/auth': patch
+---
+
+feat(console,mobile,auth): the Console registers the mobile shell's push token under its own session and unregisters it on sign-out and when the shell removes the connection
+
+Clause-②: yes (widening)
+Metadata-protocol: none
+
+- **`@object-ui/console`:** in the ObjectStack mobile shell, a signed-in person who has reached Home or an app is asked for push permission once per page load, after a short delay (never on a sign-in screen): the Console calls `getPushToken()` and registers the token with `POST /api/v1/notifications/devices`. A `null` answer (simulator, permission denied, no token) sends nothing. A token the shell rotates is registered again; an unchanged token for the same person is not sent twice. Another person signing in on the page registers the device for them, and the registry moves the token away from the previous person. An impersonation never registers, so the impersonated person's pushes never reach the administrator's device. The registration sends `deviceId` as the shell's `installId` when it has one, and `origin` only when the page was loaded over `http:` or `https:`. The device is unregistered (`DELETE /api/v1/notifications/devices/:deviceId`) before sign-out ends the session, and when the shell removes this connection; a shell that is neither iOS nor Android registers nothing. A browser is unchanged.
+- **`@object-ui/mobile`:** `ObjectStackNative` gains the optional `installId` (`string | null`), and the package exports `NATIVE_PUSH_TOKEN_CHANGED_EVENT` (`objectstack-native:pushTokenChanged`, announced as `has('pushTokenChanged')`) with its event type `NativePushTokenChangedEvent`. Both are feature-detected: a shell that predates them carries neither.
+- **`@object-ui/auth`:** `registerBeforeSignOut(step)` registers a step that `signOut()` awaits while the session still exists, and returns the function that removes it. The steps run together and can never hold sign-out back: a rejection is reported and dropped, and sign-out goes ahead after `BEFORE_SIGN_OUT_TIMEOUT_MS` (3 s) whether or not they have settled.
diff --git a/ui/apps/console/src/App.tsx b/ui/apps/console/src/App.tsx
index de1d9967c83..c47c3975825 100644
--- a/ui/apps/console/src/App.tsx
+++ b/ui/apps/console/src/App.tsx
@@ -49,6 +49,7 @@ import { studioRoutes } from './components/StudioRoute';
import { SetupRoute } from './components/SetupRoute';
import { UnmatchedRoute } from './components/UnmatchedRoute';
import { nativeShellRouteTarget } from './nativeShellRoute';
+import { useNativePushRegistration } from './nativePushRegistration';
import { FormPage } from './components/FormPage';
import { InternalFormRoute } from './components/InternalFormRoute';
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
@@ -160,6 +161,10 @@ const BASENAME = resolveBasename();
* active, else the first active app. The shell's published `currentAppName` is
* left to that guard because it is an app NAME, and only the app list turns a
* name into the address its route uses.
+ *
+ * Push registration (`useNativePushRegistration`) is called first, so its
+ * listener for a rotated token is installed in the commit that posts `ready()`,
+ * and its unregister answers the shell's removal of this connection.
*/
function NativeShellRouting() {
const location = useLocation();
@@ -174,9 +179,11 @@ function NativeShellRouting() {
},
[apps, hostHint, navigate],
);
+ const unregisterPushDevice = useNativePushRegistration(location.pathname);
const host = useNativeHostRouting({
route: `${location.pathname}${location.search}${location.hash}`,
navigate: navigateShellRoute,
+ onConnectionRemoved: unregisterPushDevice,
});
const platform = host?.platform;
useEffect(() => {
diff --git a/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx b/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx
index 41bbaeb8fb3..472ed4a247d 100644
--- a/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx
+++ b/ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx
@@ -26,14 +26,20 @@ import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import {
+ NATIVE_CONNECTION_REMOVED_EVENT,
NATIVE_HOST_GLOBAL,
NATIVE_NAVIGATE_EVENT,
resetNativeHostEventsForTests,
type ObjectStackNative,
} from '../../../../packages/mobile/src/nativeHost';
import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost';
+import {
+ NATIVE_PUSH_PROMPT_DELAY_MS,
+ NATIVE_PUSH_REGISTRATION_KEY,
+ resetNativePushRegistrationForTests,
+} from '../nativePushRegistration';
-const { passthrough, stub, shellState } = vi.hoisted(() => {
+const { passthrough, stub, shellState, authState } = vi.hoisted(() => {
const passthrough = ({ children }: { children?: ReactNode }) => <>{children}>;
const stub = (testid: string) => () =>
;
// objectstack#29750: what the shell's navigation context and the metadata
@@ -45,7 +51,10 @@ const { passthrough, stub, shellState } = vi.hoisted(() => {
loading: false,
appStatus: undefined as 'idle' | 'loading' | 'ready' | 'error' | undefined,
};
- return { passthrough, stub, shellState };
+ // objectstack#29042: who the auth context names. The default is what this
+ // file's other cases read: a user id and no session answer.
+ const authState = { value: { user: { id: 'u1' } } as Record };
+ return { passthrough, stub, shellState, authState };
});
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
@@ -90,7 +99,7 @@ vi.mock('@object-ui/app-shell', async (importOriginal) => ({
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal>()),
AuthProvider: passthrough,
- useAuth: () => ({ user: { id: 'u1' } }),
+ useAuth: () => authState.value,
}));
vi.mock('../AppContent', () => ({
@@ -166,6 +175,9 @@ beforeEach(() => {
shellState.apps = [];
shellState.loading = false;
shellState.appStatus = undefined;
+ authState.value = { user: { id: 'u1' } };
+ resetNativePushRegistrationForTests();
+ sessionStorage.clear();
window.history.pushState({}, '', '/login');
});
@@ -358,3 +370,76 @@ describe('the catch-all hosts an app-relative first URL under an app', () => {
expect(window.location.pathname).toBe('/home');
});
});
+
+/**
+ * objectstack#29042 — the router wiring of push registration: the route the
+ * router is on decides when the token is asked for, and the shell's removal of
+ * this connection is answered with the unregister, under the Console's session.
+ */
+describe('push registration through the router wiring', () => {
+ const signedIn = { user: { id: 'u1' }, session: { token: 'session' }, isAuthenticated: true };
+
+ function deviceCalls(fetchMock: ReturnType): string[] {
+ return fetchMock.mock.calls
+ .map(([url, init]) => `${(init as RequestInit | undefined)?.method ?? 'GET'} ${String(url)}`)
+ .filter((call) => call.includes('/api/v1/notifications/devices'));
+ }
+
+ function stubFetch() {
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => ({
+ ok: true,
+ status: 200,
+ headers: new Headers(),
+ json: async () => ({
+ success: true,
+ data: init?.method === 'POST' ? { deviceId: 'install-7', success: true } : { success: true },
+ }),
+ }));
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+ }
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('Home asks the shell for the token once the delay has passed, and registers it', async () => {
+ const fetchMock = stubFetch();
+ authState.value = signedIn;
+ installBridge();
+ const bridge = window[NATIVE_HOST_GLOBAL as keyof Window] as unknown as ObjectStackNative;
+ const getPushToken = vi.spyOn(bridge, 'getPushToken').mockResolvedValue({ token: 'tok-1', vendor: 'fcm' });
+ window.history.pushState({}, '', '/home');
+ render();
+ await screen.findByTestId('home-page');
+ expect(getPushToken).not.toHaveBeenCalled();
+ await vi.waitFor(
+ () => expect(deviceCalls(fetchMock)).toEqual([expect.stringMatching(/^POST .*\/api\/v1\/notifications\/devices$/)]),
+ { timeout: NATIVE_PUSH_PROMPT_DELAY_MS + 2000 },
+ );
+ expect(getPushToken).toHaveBeenCalledTimes(1);
+ });
+
+ it('the shell removing this connection waits for the unregister of the registered device', async () => {
+ const fetchMock = stubFetch();
+ authState.value = signedIn;
+ sessionStorage.setItem(
+ NATIVE_PUSH_REGISTRATION_KEY,
+ JSON.stringify({ userId: 'u1', token: 'tok-1', vendor: 'fcm', deviceId: 'install-7' }),
+ );
+ installBridge();
+ render();
+ await screen.findByTestId('login-page');
+
+ const held: Promise[] = [];
+ const event = new Event(NATIVE_CONNECTION_REMOVED_EVENT);
+ Object.assign(event, { waitUntil: (promise: Promise) => held.push(promise) });
+ act(() => {
+ window.dispatchEvent(event);
+ });
+ expect(held).toHaveLength(1);
+ await held[0];
+ expect(deviceCalls(fetchMock)).toEqual([expect.stringMatching(/^DELETE .*\/api\/v1\/notifications\/devices\/install-7$/)]);
+ expect(sessionStorage.getItem(NATIVE_PUSH_REGISTRATION_KEY)).toBeNull();
+ });
+});
diff --git a/ui/apps/console/src/__tests__/nativePushRegistration-29042.test.tsx b/ui/apps/console/src/__tests__/nativePushRegistration-29042.test.tsx
new file mode 100644
index 00000000000..385b22ab779
--- /dev/null
+++ b/ui/apps/console/src/__tests__/nativePushRegistration-29042.test.tsx
@@ -0,0 +1,368 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * The Console registers the mobile shell's push token under its own session
+ * (objectstack#29042).
+ *
+ * A fake bridge stands in for the shell and a fake `fetch` for the server: the
+ * assertions read the requests that reach `/api/v1/notifications/devices`. The
+ * auth state is the real context with a test-held value, so sign-in, a person
+ * switch and an impersonation are re-renders.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
+import { act, cleanup, render } from '@testing-library/react';
+import { useEffect, type ReactNode } from 'react';
+import { AuthCtx, type AuthContextValue } from '../../../../packages/auth/src/AuthContext';
+import { useAuth } from '../../../../packages/auth/src/useAuth';
+import { resetBeforeSignOutForTests, runBeforeSignOut } from '../../../../packages/auth/src/beforeSignOut';
+import {
+ NATIVE_HOST_GLOBAL,
+ NATIVE_PUSH_TOKEN_CHANGED_EVENT,
+ type NativePushToken,
+ type ObjectStackNative,
+} from '../../../../packages/mobile/src/nativeHost';
+import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost';
+import {
+ NATIVE_PUSH_PROMPT_DELAY_MS,
+ NATIVE_PUSH_REGISTRATION_KEY,
+ registrationOrigin,
+ resetNativePushRegistrationForTests,
+ useNativePushRegistration,
+} from '../nativePushRegistration';
+
+const DEVICES = '/api/v1/notifications/devices';
+
+interface AuthState {
+ userId: string | null;
+ impersonatedBy?: string;
+}
+
+type FakeBridge = ObjectStackNative & { getPushToken: Mock };
+
+function installBridge({
+ token = { token: 'tok-1', vendor: 'apns' },
+ platform = 'ios',
+ capabilities = ['getPushToken', 'pushTokenChanged'],
+ installId,
+}: {
+ token?: NativePushToken | null;
+ platform?: string;
+ capabilities?: readonly string[];
+ installId?: string | null;
+} = {}): FakeBridge {
+ const bridge: FakeBridge = {
+ version: 1,
+ platform,
+ appVersion: '2.1.0',
+ capabilities,
+ safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
+ ...(installId !== undefined ? { installId } : {}),
+ has: (capability: string) => capabilities.includes(capability),
+ getPushToken: vi.fn(async () => token),
+ haptic: async () => null,
+ openExternal: async () => null,
+ navigate: async () => null,
+ resetServer: async () => null,
+ ready: () => {},
+ routeChanged: () => {},
+ };
+ (window as unknown as Record)[NATIVE_HOST_GLOBAL] = bridge;
+ return bridge;
+}
+
+let fetchMock: Mock;
+let unregister: (() => Promise) | null = null;
+
+function answer(status: number, body: unknown) {
+ return { ok: status >= 200 && status < 300, status, statusText: String(status), headers: new Headers(), json: async () => body };
+}
+
+function deviceRequests(): Array<{ method: string; url: string; body: unknown }> {
+ return fetchMock.mock.calls
+ .map((call: unknown[]) => {
+ const init = call[1] as RequestInit | undefined;
+ return {
+ method: init?.method ?? 'GET',
+ url: String(call[0]),
+ body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
+ };
+ })
+ .filter((request) => request.url.includes(DEVICES));
+}
+
+function keepUnregister(fn: () => Promise) {
+ unregister = fn;
+}
+
+function Probe({ pathname }: { pathname: string }) {
+ const fn = useNativePushRegistration(pathname);
+ useEffect(() => keepUnregister(fn), [fn]);
+ return null;
+}
+
+function Harness({ auth, pathname }: { auth: AuthState; pathname: string }): ReactNode {
+ // Outside a provider `useAuth` answers its signed-out defaults; the test overrides who.
+ const signedOut = useAuth();
+ const value: AuthContextValue = {
+ ...signedOut,
+ isAuthenticated: auth.userId !== null,
+ user: auth.userId ? ({ id: auth.userId, email: `${auth.userId}@example.com`, name: auth.userId } as AuthContextValue['user']) : null,
+ session: auth.userId
+ ? ({ token: 'session', ...(auth.impersonatedBy ? { impersonatedBy: auth.impersonatedBy } : {}) } as AuthContextValue['session'])
+ : null,
+ };
+ return (
+
+
+
+ );
+}
+
+/** Let the prompt delay pass, then let the shell's answer and the request settle. */
+async function passPromptDelay() {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(NATIVE_PUSH_PROMPT_DELAY_MS);
+ });
+}
+
+async function settle() {
+ await act(async () => {
+ for (let i = 0; i < 10; i += 1) await Promise.resolve();
+ });
+}
+
+beforeEach(() => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ resetNativeHostWatchForTests();
+ resetNativePushRegistrationForTests();
+ resetBeforeSignOutForTests();
+ sessionStorage.clear();
+ unregister = null;
+ fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
+ if (String(url).endsWith(DEVICES) && init?.method === 'POST') {
+ const body = JSON.parse(String(init.body)) as { deviceId?: string };
+ return answer(200, { success: true, data: { deviceId: body.deviceId ?? 'minted-1', success: true } });
+ }
+ if (String(url).includes(`${DEVICES}/`) && init?.method === 'DELETE') {
+ return answer(200, { success: true, data: { success: true } });
+ }
+ return answer(404, { success: false, error: { code: 'NOT_FOUND', message: 'no' } });
+ });
+ vi.stubGlobal('fetch', fetchMock);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ delete (window as unknown as Record)[NATIVE_HOST_GLOBAL];
+});
+
+describe('push registration in the mobile shell (objectstack#29042)', () => {
+ it('a signed-in person on Home: the token is asked for after the delay, once, and registered', async () => {
+ const bridge = installBridge({ installId: 'install-7' });
+ render();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(NATIVE_PUSH_PROMPT_DELAY_MS - 1);
+ });
+ expect(bridge.getPushToken).not.toHaveBeenCalled();
+
+ await passPromptDelay();
+ await settle();
+ expect(bridge.getPushToken).toHaveBeenCalledTimes(1);
+ expect(deviceRequests()).toEqual([
+ {
+ method: 'POST',
+ url: expect.stringContaining(DEVICES),
+ body: {
+ token: 'tok-1',
+ vendor: 'apns',
+ platform: 'ios',
+ deviceId: 'install-7',
+ origin: window.location.origin,
+ },
+ },
+ ]);
+ expect(JSON.parse(sessionStorage.getItem(NATIVE_PUSH_REGISTRATION_KEY) ?? 'null')).toEqual({
+ userId: 'u1',
+ token: 'tok-1',
+ vendor: 'apns',
+ deviceId: 'install-7',
+ });
+ });
+
+ it('no installId: no deviceId is sent, and the id the server minted is kept for the session', async () => {
+ installBridge({ installId: null });
+ render();
+ await passPromptDelay();
+ await settle();
+ const [request] = deviceRequests();
+ expect(request.body).not.toHaveProperty('deviceId');
+ expect(JSON.parse(sessionStorage.getItem(NATIVE_PUSH_REGISTRATION_KEY) ?? 'null')).toMatchObject({ deviceId: 'minted-1' });
+ });
+
+ it('a sign-in surface, or no session: the shell is never asked, so the OS prompt never shows there', async () => {
+ const bridge = installBridge();
+ const { rerender } = render();
+ await passPromptDelay();
+ rerender();
+ await passPromptDelay();
+ await settle();
+ expect(bridge.getPushToken).not.toHaveBeenCalled();
+ expect(deviceRequests()).toEqual([]);
+ });
+
+ it('leaving Home before the delay passes asks nothing until the person is back', async () => {
+ const bridge = installBridge();
+ const { rerender } = render();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(NATIVE_PUSH_PROMPT_DELAY_MS / 2);
+ });
+ rerender();
+ await passPromptDelay();
+ expect(bridge.getPushToken).not.toHaveBeenCalled();
+ });
+
+ it('a null token (simulator, permission denied) sends no request', async () => {
+ const bridge = installBridge({ token: null });
+ render();
+ await passPromptDelay();
+ await settle();
+ expect(bridge.getPushToken).toHaveBeenCalledTimes(1);
+ expect(deviceRequests()).toEqual([]);
+ });
+
+ it('an impersonation registers nothing: the device stays the administrator’s', async () => {
+ const bridge = installBridge();
+ render();
+ await passPromptDelay();
+ await settle();
+ expect(bridge.getPushToken).not.toHaveBeenCalled();
+ expect(deviceRequests()).toEqual([]);
+ });
+
+ it('a shell that is neither iOS nor Android registers nothing', async () => {
+ installBridge({ platform: 'desktop' });
+ render();
+ await passPromptDelay();
+ await settle();
+ expect(deviceRequests()).toEqual([]);
+ });
+
+ it('a rotated token is registered; the same token again is not sent twice', async () => {
+ installBridge({ installId: 'install-7' });
+ render();
+ await passPromptDelay();
+ await settle();
+
+ const rotate = (token: string) =>
+ act(() => {
+ window.dispatchEvent(new CustomEvent(NATIVE_PUSH_TOKEN_CHANGED_EVENT, { detail: { token, vendor: 'apns' } }));
+ });
+ rotate('tok-2');
+ await settle();
+ rotate('tok-2');
+ await settle();
+
+ expect(deviceRequests().map((r) => (r.body as { token: string }).token)).toEqual(['tok-1', 'tok-2']);
+ });
+
+ it('a rotation that arrives before anyone signs in is registered at sign-in, without asking the shell', async () => {
+ const bridge = installBridge();
+ const { rerender } = render();
+ act(() => {
+ window.dispatchEvent(
+ new CustomEvent(NATIVE_PUSH_TOKEN_CHANGED_EVENT, { detail: { token: 'tok-early', vendor: 'fcm' } }),
+ );
+ });
+ rerender();
+ await settle();
+ expect(bridge.getPushToken).not.toHaveBeenCalled();
+ expect(deviceRequests().map((r) => r.body)).toEqual([
+ expect.objectContaining({ token: 'tok-early', vendor: 'fcm', platform: 'ios' }),
+ ]);
+ });
+
+ it('another person signing in on the page registers the device for them', async () => {
+ const bridge = installBridge({ installId: 'install-7' });
+ const { rerender } = render();
+ await passPromptDelay();
+ await settle();
+ rerender();
+ rerender();
+ await settle();
+ expect(bridge.getPushToken).toHaveBeenCalledTimes(1);
+ expect(deviceRequests()).toHaveLength(2);
+ expect(JSON.parse(sessionStorage.getItem(NATIVE_PUSH_REGISTRATION_KEY) ?? 'null')).toMatchObject({ userId: 'u2' });
+ });
+});
+
+describe('unregistering the device this Console registered (objectstack#29042)', () => {
+ async function registered(auth: AuthState = { userId: 'u1' }) {
+ installBridge({ installId: 'install-7' });
+ const view = render();
+ await passPromptDelay();
+ await settle();
+ return view;
+ }
+
+ it('the connection-removed answer deletes the device under the session and forgets it', async () => {
+ await registered();
+ await act(async () => {
+ await unregister!();
+ });
+ expect(deviceRequests()[deviceRequests().length - 1]).toEqual({ method: 'DELETE', url: expect.stringContaining(`${DEVICES}/install-7`), body: undefined });
+ expect(sessionStorage.getItem(NATIVE_PUSH_REGISTRATION_KEY)).toBeNull();
+ });
+
+ it('sign-out runs the unregister before the session ends', async () => {
+ await registered();
+ await act(async () => {
+ await runBeforeSignOut();
+ });
+ expect(deviceRequests().map((r) => r.method)).toEqual(['POST', 'DELETE']);
+ });
+
+ it('a device the registry no longer holds (404) counts as unregistered', async () => {
+ await registered();
+ fetchMock.mockImplementation(async () => answer(404, { success: false, error: { code: 'NOT_FOUND', message: 'gone' } }));
+ await expect(unregister!()).resolves.toBeUndefined();
+ expect(sessionStorage.getItem(NATIVE_PUSH_REGISTRATION_KEY)).toBeNull();
+ });
+
+ it('a failed unregister rejects, so the shell records the token as left registered', async () => {
+ await registered();
+ fetchMock.mockImplementation(async () => answer(500, { success: false, error: { code: 'INTERNAL_ERROR', message: 'boom' } }));
+ await expect(unregister!()).rejects.toThrow();
+ });
+
+ it('no session left to unregister with rejects, so the shell records the token as left registered', async () => {
+ const { rerender } = await registered();
+ rerender();
+ await expect(unregister!()).rejects.toThrow(/no session/);
+ });
+
+ it('nothing registered and no installId: resolves without a request', async () => {
+ installBridge({ installId: null });
+ render();
+ await expect(unregister!()).resolves.toBeUndefined();
+ expect(deviceRequests()).toEqual([]);
+ });
+});
+
+describe('the origin a registration sends (objectstack#29042)', () => {
+ it('http and https send the page origin; an opaque origin sends none', () => {
+ expect(registrationOrigin({ protocol: 'https:', origin: 'https://crm.example.com' })).toBe('https://crm.example.com');
+ expect(registrationOrigin({ protocol: 'http:', origin: 'http://localhost:5180' })).toBe('http://localhost:5180');
+ expect(registrationOrigin({ protocol: 'about:', origin: 'null' })).toBeUndefined();
+ expect(registrationOrigin({ protocol: 'data:', origin: 'null' })).toBeUndefined();
+ });
+});
diff --git a/ui/apps/console/src/nativePushRegistration.ts b/ui/apps/console/src/nativePushRegistration.ts
new file mode 100644
index 00000000000..7b26f67f47a
--- /dev/null
+++ b/ui/apps/console/src/nativePushRegistration.ts
@@ -0,0 +1,301 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Native shell push registration: the Console registers the mobile shell's
+ * push token with the server's device registry (`POST
+ * /api/v1/notifications/devices`) under its own session, and unregisters it
+ * when the shell removes this Console's connection.
+ *
+ * The shell holds no API credential, so the Console is the only party that
+ * can register. What it does, in the shell only (a browser installs nothing):
+ *
+ * - Asks the shell for the token (`getPushToken()`, announced as the
+ * `getPushToken` capability) once per page load, and only once a signed-in
+ * person has reached a meaningful screen (`/home` or an app) and stayed on it
+ * for `NATIVE_PUSH_PROMPT_DELAY_MS`. The first call shows the OS permission
+ * prompt, so it never runs on a sign-in surface. A `null` answer (simulator,
+ * permission denied, no token) sends nothing.
+ * - Registers the token, and re-registers when the shell announces a rotated
+ * one (`objectstack-native:pushTokenChanged`, announced as the
+ * `pushTokenChanged` capability) or another person signs in on this page.
+ * The last registration is kept for the session (`sessionStorage`); an equal
+ * token for the same person is not sent again. The registry moves a token
+ * from whoever held it, so a new person's registration takes the device over
+ * from the previous one.
+ * - Never registers while the session is an impersonation: that would move
+ * the impersonated person's pushes onto the administrator's device.
+ * - Sends `deviceId` as the shell's `installId` when the shell exposes one as
+ * a string, else the id the server answered last time, else none.
+ * - Sends `origin` only when the page was loaded over `http:` or `https:`: an
+ * opaque origin reads `"null"`, which the request grammar refuses.
+ *
+ * `installId` and the rotation event are optional members of the bridge and
+ * are feature-detected, so an older shell keeps working.
+ */
+
+import { useEffect, useRef, useState } from 'react';
+import { ObjectStackClient } from '@objectstack/client';
+import { createAuthenticatedFetch, registerBeforeSignOut, useAuth } from '@object-ui/auth';
+import {
+ NATIVE_PUSH_TOKEN_CHANGED_EVENT,
+ nativeGetPushToken,
+ nativeHostCan,
+ useNativeHost,
+ type NativePushToken,
+ type NativePushTokenChangedEvent,
+ type ObjectStackNative,
+} from '@object-ui/mobile';
+
+/** The capability a shell announces when it dispatches `NATIVE_PUSH_TOKEN_CHANGED_EVENT`. */
+const PUSH_TOKEN_CHANGED_CAPABILITY = 'pushTokenChanged';
+
+/**
+ * How long a signed-in person stays on a meaningful screen before the Console
+ * asks the shell for the token (and the OS shows its permission prompt).
+ */
+export const NATIVE_PUSH_PROMPT_DELAY_MS = 1500;
+
+/** Where the session's last registration is kept. */
+export const NATIVE_PUSH_REGISTRATION_KEY = 'objectui:native-push:registration';
+
+/** The session's last registration: who, which token, and the device id the server answered. */
+interface PushRegistrationRecord {
+ userId: string;
+ token: string;
+ vendor: NativePushToken['vendor'];
+ deviceId: string;
+}
+
+const SERVER_URL = import.meta.env.VITE_SERVER_URL || '';
+
+let client: ObjectStackClient | null = null;
+/** Whether this page load has asked the shell for the token yet. */
+let tokenAsked = false;
+/** The latest token this page load has seen, from `getPushToken()` or the rotation event. */
+let knownToken: NativePushToken | null = null;
+/** Registrations run one after another, so two triggers never race to the registry. */
+let queue: Promise = Promise.resolve();
+
+function pushClient(): ObjectStackClient {
+ client ??= new ObjectStackClient({ baseUrl: SERVER_URL, fetch: createAuthenticatedFetch() });
+ return client;
+}
+
+function readRecord(): PushRegistrationRecord | null {
+ try {
+ const raw = sessionStorage.getItem(NATIVE_PUSH_REGISTRATION_KEY);
+ if (!raw) return null;
+ const value: unknown = JSON.parse(raw);
+ if (!value || typeof value !== 'object') return null;
+ const { userId, token, vendor, deviceId } = value as Record;
+ if (typeof userId !== 'string' || typeof token !== 'string' || typeof deviceId !== 'string') return null;
+ if (vendor !== 'apns' && vendor !== 'fcm') return null;
+ return { userId, token, vendor, deviceId };
+ } catch {
+ return null;
+ }
+}
+
+function writeRecord(record: PushRegistrationRecord): void {
+ try {
+ sessionStorage.setItem(NATIVE_PUSH_REGISTRATION_KEY, JSON.stringify(record));
+ } catch {
+ /* storage unavailable: the next trigger registers again, which the registry treats as a refresh */
+ }
+}
+
+function clearRecord(): void {
+ try {
+ sessionStorage.removeItem(NATIVE_PUSH_REGISTRATION_KEY);
+ } catch {
+ /* storage unavailable */
+ }
+}
+
+/** The shell's `installId` when it exposes one as a string (a shell that predates it carries none). */
+function installIdOf(host: ObjectStackNative): string | null {
+ const { installId } = host;
+ return typeof installId === 'string' && installId !== '' ? installId : null;
+}
+
+/** The registry's platform for this shell, or `null` for a shell that is neither iOS nor Android. */
+function platformOf(host: ObjectStackNative): 'ios' | 'android' | null {
+ const { platform } = host;
+ if (platform === 'ios') return 'ios';
+ if (platform === 'android') return 'android';
+ return null;
+}
+
+/**
+ * The `origin` a registration sends: the page's origin, only when it was loaded
+ * over http or https. A sandboxed or `about:blank` / `data:` page reads the
+ * opaque `"null"`, which the request grammar refuses, and that would refuse the
+ * whole registration; such a page sends no `origin`.
+ */
+export function registrationOrigin(location: Pick): string | undefined {
+ return location.protocol === 'http:' || location.protocol === 'https:' ? location.origin : undefined;
+}
+
+function isPushToken(value: unknown): value is NativePushToken {
+ if (!value || typeof value !== 'object') return false;
+ const { token, vendor } = value as Record;
+ return typeof token === 'string' && token !== '' && (vendor === 'apns' || vendor === 'fcm');
+}
+
+function shellAnnounces(host: ObjectStackNative, capability: string): boolean {
+ try {
+ return typeof host.has === 'function' && host.has(capability) === true;
+ } catch {
+ return false;
+ }
+}
+
+/** Register `token` for `userId` unless the session already registered exactly that. */
+function syncRegistration(host: ObjectStackNative, userId: string, token: NativePushToken): Promise {
+ queue = queue.then(async () => {
+ const platform = platformOf(host);
+ if (!platform) return;
+ const record = readRecord();
+ if (record && record.userId === userId && record.token === token.token && record.vendor === token.vendor) return;
+ const deviceId = installIdOf(host) ?? (record?.userId === userId ? record.deviceId : undefined);
+ const origin = typeof window === 'undefined' ? undefined : registrationOrigin(window.location);
+ try {
+ const answer = await pushClient().notifications.registerDevice({
+ token: token.token,
+ vendor: token.vendor,
+ platform,
+ ...(deviceId ? { deviceId } : {}),
+ ...(origin ? { origin } : {}),
+ });
+ writeRecord({ userId, token: token.token, vendor: token.vendor, deviceId: answer.deviceId });
+ } catch (error) {
+ console.warn('[native-push] the device registration failed; pushes will not reach this device until it succeeds', error);
+ }
+ });
+ return queue;
+}
+
+/** Whether `pathname` is a screen the person came for: Home or an app. */
+function isMeaningfulScreen(pathname: string): boolean {
+ return pathname === '/home' || pathname.startsWith('/home/') || pathname.startsWith('/apps/');
+}
+
+interface LatestState {
+ host: ObjectStackNative | null;
+ userId: string | null;
+ signedIn: boolean;
+}
+
+/**
+ * Unregister the device this Console registered, under the current session.
+ * Resolves when nothing is left registered; rejects when the device may still
+ * be registered (no session to unregister with, or the request failed).
+ */
+async function unregisterThisDevice(latest: { current: LatestState }): Promise {
+ await queue;
+ const current = latest.current;
+ const record = readRecord();
+ const deviceId = record?.deviceId ?? (current.host ? installIdOf(current.host) : null);
+ if (!deviceId) return;
+ if (!current.signedIn) {
+ throw new Error('[native-push] no session to unregister this device with; it may still be registered');
+ }
+ try {
+ await pushClient().notifications.unregisterDevice(deviceId);
+ } catch (error) {
+ // The registry no longer holds it (or never did): nothing is left registered.
+ if ((error as { httpStatus?: unknown } | null)?.httpStatus !== 404) throw error;
+ }
+ clearRecord();
+}
+
+/**
+ * Wires push registration to the native shell. Call it from the component that
+ * calls `useNativeHostRouting`, before that call, and pass what it returns as
+ * that call's `onConnectionRemoved`: the rotation listener is then installed in
+ * the same commit that posts `ready()`, which is when the shell releases a
+ * token it held. `pathname` is the router's path without the basename.
+ *
+ * The returned function unregisters the device this Console registered, under
+ * its own session, and `signOut()` runs it before the session ends. It
+ * resolves when there is nothing to unregister or the registry no longer holds
+ * the device, and rejects when the device may still be registered, so the
+ * shell records the truth. Its identity is stable for the component's life.
+ */
+export function useNativePushRegistration(pathname: string): () => Promise {
+ const host = useNativeHost();
+ const { user, session, isAuthenticated } = useAuth();
+ const signedIn = isAuthenticated && typeof user?.id === 'string' && user.id !== '';
+ // An impersonation never registers: it would move the impersonated person's
+ // pushes onto the administrator's device.
+ const userId = signedIn && !session?.impersonatedBy ? (user?.id ?? null) : null;
+ const meaningful = isMeaningfulScreen(pathname);
+
+ const latest = useRef({ host, userId, signedIn });
+ useEffect(() => {
+ latest.current = { host, userId, signedIn };
+ });
+ // `useState`'s initializer runs once, so the identity never changes (a memo may be discarded).
+ const [unregister] = useState(() => () => unregisterThisDevice(latest));
+
+ // A token the shell rotated: register it for whoever is signed in now, or
+ // keep it for the next person who signs in on this page.
+ useEffect(() => {
+ if (!host || !shellAnnounces(host, PUSH_TOKEN_CHANGED_CAPABILITY)) return undefined;
+ const onTokenChanged = (event: Event) => {
+ const { detail } = event as Partial;
+ if (!isPushToken(detail)) return;
+ knownToken = { token: detail.token, vendor: detail.vendor };
+ const current = latest.current;
+ if (current.host && current.userId) void syncRegistration(current.host, current.userId, knownToken);
+ };
+ window.addEventListener(NATIVE_PUSH_TOKEN_CHANGED_EVENT, onTokenChanged);
+ return () => window.removeEventListener(NATIVE_PUSH_TOKEN_CHANGED_EVENT, onTokenChanged);
+ }, [host]);
+
+ // A signed-in person with a token already known: register it for them.
+ useEffect(() => {
+ if (!host || !userId || !knownToken) return;
+ void syncRegistration(host, userId, knownToken);
+ }, [host, userId]);
+
+ // No token yet: ask the shell once per page load, after the person has
+ // reached a meaningful screen, so the OS prompt never interrupts sign-in.
+ useEffect(() => {
+ if (!host || !userId || !meaningful || tokenAsked || knownToken) return undefined;
+ if (!nativeHostCan(host, 'getPushToken')) return undefined;
+ const timer = window.setTimeout(() => {
+ if (tokenAsked) return;
+ tokenAsked = true;
+ void nativeGetPushToken().then((result) => {
+ if (result.status !== 'ok' || !isPushToken(result.value)) return;
+ knownToken = { token: result.value.token, vendor: result.value.vendor };
+ const current = latest.current;
+ if (current.host && current.userId) void syncRegistration(current.host, current.userId, knownToken);
+ });
+ }, NATIVE_PUSH_PROMPT_DELAY_MS);
+ return () => window.clearTimeout(timer);
+ }, [host, userId, meaningful]);
+
+ // Signing out ends the session the unregister needs, so it runs first.
+ useEffect(() => {
+ if (!host) return undefined;
+ return registerBeforeSignOut(unregister);
+ }, [host, unregister]);
+
+ return unregister;
+}
+
+/** Test seam: forget this page load's token, ask and client. */
+export function resetNativePushRegistrationForTests(): void {
+ client = null;
+ tokenAsked = false;
+ knownToken = null;
+ queue = Promise.resolve();
+}
diff --git a/ui/packages/auth/src/AuthProvider.tsx b/ui/packages/auth/src/AuthProvider.tsx
index fa3783c7139..8040ac583c0 100644
--- a/ui/packages/auth/src/AuthProvider.tsx
+++ b/ui/packages/auth/src/AuthProvider.tsx
@@ -8,6 +8,7 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { authGateEvents } from './auth-gate-events.js';
+import { runBeforeSignOut } from './beforeSignOut.js';
import type { AuthUser, AuthClient, AuthProviderOptions, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthInvitationPreview, AuthPublicConfig, SignInWithProviderOptions, SignInTwoFactorCodeKind, TwoFactorChallenge } from './types.js';
import { TWO_FACTOR_CHALLENGE_ENDED_CODES } from './two-factor-codes.js';
import { AuthCtx, type AuthContextValue } from './AuthContext.js';
@@ -491,6 +492,9 @@ export function AuthProvider({
const signOut = useCallback(async () => {
setIsLoading(true);
try {
+ // Steps that need the session (a push device's unregister) run while it
+ // still exists. Bounded, and never rejects: it cannot hold sign-out back.
+ await runBeforeSignOut();
await client.signOut();
setUser(null);
setSession(null);
diff --git a/ui/packages/auth/src/__tests__/AuthProvider.test.tsx b/ui/packages/auth/src/__tests__/AuthProvider.test.tsx
index 43b5219b2ae..408bdbbbc31 100644
--- a/ui/packages/auth/src/__tests__/AuthProvider.test.tsx
+++ b/ui/packages/auth/src/__tests__/AuthProvider.test.tsx
@@ -10,6 +10,7 @@ import { AuthProvider } from '../AuthProvider';
import { useAuth } from '../useAuth';
import { AuthGuard } from '../AuthGuard';
import type { AuthClient } from '../types';
+import { registerBeforeSignOut, resetBeforeSignOutForTests } from '../beforeSignOut';
function createMockClient(overrides: Partial = {}): AuthClient {
return {
@@ -209,6 +210,73 @@ describe('useAuth', () => {
});
});
+/**
+ * objectstack#29042 — a step registered with `registerBeforeSignOut` runs while
+ * the session still exists: `signOut()` awaits it before `client.signOut()`,
+ * and a failing step does not hold the sign-out back.
+ */
+describe('signOut runs the before-sign-out steps first', () => {
+ function SignOutButton() {
+ const { signOut, isAuthenticated } = useAuth();
+ return (
+
+
+ {String(isAuthenticated)}
+
+ );
+ }
+
+ function signedInClient(order: string[]) {
+ return createMockClient({
+ getSession: vi.fn().mockResolvedValue({
+ user: { id: '1', name: 'Test', email: 'test@test.com' },
+ session: { token: 'tok' },
+ }),
+ signOut: vi.fn().mockImplementation(async () => {
+ order.push('client.signOut');
+ }),
+ });
+ }
+
+ async function signOutThrough(client: AuthClient) {
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId('auth').textContent).toBe('true'));
+ await act(async () => {
+ await userEvent.click(screen.getByText('Sign Out'));
+ });
+ await waitFor(() => expect(screen.getByTestId('auth').textContent).toBe('false'));
+ }
+
+ it('awaits a registered step before the session ends', async () => {
+ resetBeforeSignOutForTests();
+ const order: string[] = [];
+ const remove = registerBeforeSignOut(async () => {
+ await Promise.resolve();
+ order.push('step');
+ });
+ await signOutThrough(signedInClient(order));
+ expect(order).toEqual(['step', 'client.signOut']);
+ remove();
+ });
+
+ it('a step that fails does not stop the sign-out', async () => {
+ resetBeforeSignOutForTests();
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const order: string[] = [];
+ const remove = registerBeforeSignOut(async () => {
+ throw new Error('unregister failed');
+ });
+ await signOutThrough(signedInClient(order));
+ expect(order).toEqual(['client.signOut']);
+ remove();
+ warn.mockRestore();
+ });
+});
+
describe('AuthGuard', () => {
it('shows loading fallback while loading', () => {
const client = createMockClient({
diff --git a/ui/packages/auth/src/__tests__/beforeSignOut-29042.test.ts b/ui/packages/auth/src/__tests__/beforeSignOut-29042.test.ts
new file mode 100644
index 00000000000..3bcb7d9a97e
--- /dev/null
+++ b/ui/packages/auth/src/__tests__/beforeSignOut-29042.test.ts
@@ -0,0 +1,78 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * The steps `signOut()` awaits while the session still exists
+ * (objectstack#29042): every one runs, none can hold the sign-out back.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ BEFORE_SIGN_OUT_TIMEOUT_MS,
+ registerBeforeSignOut,
+ resetBeforeSignOutForTests,
+ runBeforeSignOut,
+} from '../beforeSignOut';
+
+beforeEach(() => {
+ resetBeforeSignOutForTests();
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe('registerBeforeSignOut (objectstack#29042)', () => {
+ it('runs every registered step and waits for them', async () => {
+ const order: string[] = [];
+ registerBeforeSignOut(async () => {
+ await Promise.resolve();
+ order.push('a');
+ });
+ registerBeforeSignOut(async () => {
+ order.push('b');
+ });
+ await runBeforeSignOut();
+ expect(order.sort()).toEqual(['a', 'b']);
+ });
+
+ it('a rejected step is reported and does not reject the run', async () => {
+ const after = vi.fn(async () => {});
+ registerBeforeSignOut(async () => {
+ throw new Error('unregister failed');
+ });
+ registerBeforeSignOut(after);
+ await expect(runBeforeSignOut()).resolves.toBeUndefined();
+ expect(after).toHaveBeenCalledTimes(1);
+ expect(console.warn).toHaveBeenCalled();
+ });
+
+ it('a step that never settles is cut off at the timeout', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ registerBeforeSignOut(() => new Promise(() => {}));
+ let done = false;
+ const run = runBeforeSignOut().then(() => {
+ done = true;
+ });
+ await vi.advanceTimersByTimeAsync(BEFORE_SIGN_OUT_TIMEOUT_MS - 1);
+ expect(done).toBe(false);
+ await vi.advanceTimersByTimeAsync(1);
+ await run;
+ expect(done).toBe(true);
+ });
+
+ it('a removed step no longer runs', async () => {
+ const step = vi.fn(async () => {});
+ const remove = registerBeforeSignOut(step);
+ remove();
+ await runBeforeSignOut();
+ expect(step).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/packages/auth/src/beforeSignOut.ts b/ui/packages/auth/src/beforeSignOut.ts
new file mode 100644
index 00000000000..13a662fd74a
--- /dev/null
+++ b/ui/packages/auth/src/beforeSignOut.ts
@@ -0,0 +1,74 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Steps that must run under the session that is about to end.
+ *
+ * Some cleanup can only happen while the session still exists: once
+ * `signOut()` has ended it, the server answers every request 401. Unregistering
+ * this device's push token is the case in point: left registered, a
+ * signed-out device keeps receiving the person's pushes. A caller registers
+ * such a step with `registerBeforeSignOut`, and `AuthProvider`'s `signOut()`
+ * awaits every registered step before it ends the session.
+ *
+ * A step never holds sign-out back: the steps run together, a rejection is
+ * reported and dropped, and sign-out goes ahead after
+ * `BEFORE_SIGN_OUT_TIMEOUT_MS` whether or not they have settled.
+ */
+
+/** The longest `signOut()` waits for the registered steps, in milliseconds. */
+export const BEFORE_SIGN_OUT_TIMEOUT_MS = 3000;
+
+type BeforeSignOutStep = () => Promise;
+
+const steps = new Set();
+
+/**
+ * Run `step` under the current session each time `signOut()` is called, before
+ * the session ends. Returns the function that removes it again.
+ */
+export function registerBeforeSignOut(step: BeforeSignOutStep): () => void {
+ steps.add(step);
+ return () => {
+ steps.delete(step);
+ };
+}
+
+/**
+ * Run every registered step and settle once all have settled or `timeoutMs`
+ * has passed. Never rejects.
+ */
+export async function runBeforeSignOut(timeoutMs: number = BEFORE_SIGN_OUT_TIMEOUT_MS): Promise {
+ if (steps.size === 0) return;
+ const settled = Promise.allSettled(
+ Array.from(steps, (step) =>
+ Promise.resolve()
+ .then(step)
+ .catch((error: unknown) => {
+ console.warn('[auth] a step registered to run before sign-out failed; signing out anyway', error);
+ }),
+ ),
+ );
+ let timer: ReturnType | undefined;
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => {
+ console.warn(`[auth] the steps registered to run before sign-out did not settle within ${timeoutMs} ms; signing out anyway`);
+ resolve();
+ }, timeoutMs);
+ });
+ try {
+ await Promise.race([settled, timeout]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** Test seam: forget every registered step. Not exported from the package entry. */
+export function resetBeforeSignOutForTests(): void {
+ steps.clear();
+}
diff --git a/ui/packages/auth/src/index.ts b/ui/packages/auth/src/index.ts
index 01fcc9fda50..05887449305 100644
--- a/ui/packages/auth/src/index.ts
+++ b/ui/packages/auth/src/index.ts
@@ -23,6 +23,9 @@
export { AuthProvider, type AuthProviderProps } from './AuthProvider.js';
export { useAuth } from './useAuth.js';
+// Steps `signOut()` awaits while the session still exists (a push device's
+// unregister): bounded, and never able to hold the sign-out back.
+export { registerBeforeSignOut, BEFORE_SIGN_OUT_TIMEOUT_MS } from './beforeSignOut.js';
// objectui#5619 — replaces `useIsWorkspaceAdmin(): boolean`, which had no way
// to say "not resolved yet" and so answered "not an admin" about an
// administrator whose adminship lives only in the active member row. The old
diff --git a/ui/packages/mobile/README.md b/ui/packages/mobile/README.md
index a1a57bb091f..f7eb0e8f21d 100644
--- a/ui/packages/mobile/README.md
+++ b/ui/packages/mobile/README.md
@@ -335,6 +335,15 @@ shell; the bridge contract is owned by the mobile app (`docs/HYBRID-SHELL.md`,
connection in the shell: it calls `event.waitUntil()` with the promise its optional
`onConnectionRemoved` returns (unregister the device's push token under the page's
own session), or with one already resolved when the option is omitted.
+- Push registration belongs to the page, under its own session: the shell holds no
+ API credential. `nativeGetPushToken()` resolves the token (the first call shows the
+ OS permission prompt, so call it after a meaningful screen, not during sign-in).
+ `installId`, a string on shells that carry it and `null` off the configured
+ server's origin, is the `deviceId` to register with; a shell that rotates the
+ token dispatches `NATIVE_PUSH_TOKEN_CHANGED_EVENT`
+ (`objectstack-native:pushTokenChanged`, announced as `has('pushTokenChanged')`)
+ with the new `NativePushToken` as `detail`, held until the page posts `ready()`.
+ Both are optional: feature-detect them.
- `ready()` means "the page has painted its own UI", which may be a loading fallback,
such as an auth guard's spinner while the session is read. The shell lifts its
loading cover on it, so the cover hands over to that spinner instead of staying up
diff --git a/ui/packages/mobile/src/index.ts b/ui/packages/mobile/src/index.ts
index a1305b6d6ff..f121acaf622 100644
--- a/ui/packages/mobile/src/index.ts
+++ b/ui/packages/mobile/src/index.ts
@@ -67,6 +67,7 @@ export {
NATIVE_HOST_GLOBAL,
NATIVE_NAVIGATE_EVENT,
NATIVE_CONNECTION_REMOVED_EVENT,
+ NATIVE_PUSH_TOKEN_CHANGED_EVENT,
getNativeHost,
isInAppRoute,
nativeGetPushToken,
@@ -94,6 +95,7 @@ export {
type NativeHapticKind,
type NativeHostMethod,
type NativePushToken,
+ type NativePushTokenChangedEvent,
type NativeSafeArea,
type NativeScanOptions,
type NativePickImageOptions,
diff --git a/ui/packages/mobile/src/nativeHost.ts b/ui/packages/mobile/src/nativeHost.ts
index 6bdce8b2111..b6ae2130c86 100644
--- a/ui/packages/mobile/src/nativeHost.ts
+++ b/ui/packages/mobile/src/nativeHost.ts
@@ -56,6 +56,16 @@ export interface NativeConnectionRemovedEvent extends Event {
waitUntil(promise: Promise): void;
}
+/**
+ * The window event the shell dispatches when the OS rotates this device's push
+ * token, announced as the `pushTokenChanged` capability. `detail` is the new
+ * `NativePushToken`. The shell holds a rotation until the page posts `ready()`,
+ * so a listener installed in the commit that posts `ready` receives it. The
+ * event can repeat an unchanged token: treat each one as "register this token".
+ * See `NativePushTokenChangedEvent`.
+ */
+export const NATIVE_PUSH_TOKEN_CHANGED_EVENT = 'objectstack-native:pushTokenChanged';
+
/** Haptic feedback kinds the shell accepts. */
export type NativeHapticKind =
| 'light'
@@ -72,6 +82,9 @@ export interface NativePushToken {
vendor: 'apns' | 'fcm';
}
+/** The `objectstack-native:pushTokenChanged` event: a window `CustomEvent` carrying the new token. */
+export type NativePushTokenChangedEvent = CustomEvent;
+
/** Safe-area insets in CSS pixels. Also published as `--os-native-safe-top|bottom|left|right`. */
export interface NativeSafeArea {
top: number;
@@ -165,6 +178,13 @@ export interface ObjectStackNative {
/** Feature-detect with `has()`. */
readonly capabilities: readonly string[];
readonly safeArea: NativeSafeArea;
+ /**
+ * A random id for this app install that survives WebView storage loss: the
+ * `deviceId` a push registration sends. Exposed only to pages on the shell's
+ * configured server origin; every other page reads `null`. Optional: a shell
+ * older than it does not carry it, so use it only when it is a string.
+ */
+ readonly installId?: string | null;
has(capability: string): boolean;
getPushToken(): Promise;