diff --git a/.changeset/29039-mobile-native-shell-mode.md b/.changeset/29039-mobile-native-shell-mode.md
index d8ac743349b..b8f0a953e73 100644
--- a/.changeset/29039-mobile-native-shell-mode.md
+++ b/.changeset/29039-mobile-native-shell-mode.md
@@ -13,4 +13,4 @@ The ObjectStack mobile app hosts the web Console in a WebView and injects a brid
- **`@object-ui/mobile` gains the bridge type and detection.** `ObjectStackNative` types exactly the members of the shell's bridge contract (`version`, `platform`, `appVersion`, `capabilities`, `safeArea`, `has`, the five calls and the two events). `useNativeHost()` returns it, or `null` outside the shell; it reads the global lazily and re-checks at `DOMContentLoaded`, `load` and a few points after `load`, so a late injection still arrives. `getNativeHost()` is the same read outside React.
- **Typed calls that never throw.** `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate` and `nativeResetServer` check `has(capability)` and that the method exists, and resolve a `NativeCallResult`: `{ status: 'unavailable' }`, `{ status: 'ok', value }`, or `{ status: 'failed', code, message }`.
- **Router wiring.** `useNativeHostRouting({ route, navigate })` posts `ready()` once per page load after the first route has rendered, posts `routeChanged(route)` once per route change, and answers the shell's cancelable `objectstack-native:navigate` window event by moving the router to `detail.route` (an in-app path only) and cancelling the event, so a notification tap does not reload the Console. Outside the shell it installs nothing.
-- **The Console uses it.** In the shell the Console posts those events from its router, marks the root element with `data-os-native-shell="ios|android"`, and drops the PWA install manifest. It adds no safe-area padding: the shell already pads its WebView. Without the global the Console behaves exactly as before.
+- **The Console uses it.** In the shell the Console posts those events from its router, marks the root element with `data-os-native-shell` set to the shell's platform string (an open set: `ios` and `android` from the mobile app, another value from another shell), and drops the PWA install manifest. It adds no safe-area padding: the shell already pads its WebView. Without the global the Console behaves exactly as before.
diff --git a/.changeset/29330-native-host-open-platform.md b/.changeset/29330-native-host-open-platform.md
new file mode 100644
index 00000000000..d176522c6b3
--- /dev/null
+++ b/.changeset/29330-native-host-open-platform.md
@@ -0,0 +1,14 @@
+---
+'@object-ui/mobile': patch
+---
+
+fix(mobile): the native bridge's `platform` is an open set, and a bridge speaking a newer protocol reads as no bridge
+
+Clause-②: yes (widening)
+Metadata-protocol: none
+
+The ObjectStack desktop shell hosts the Console through the same `window.ObjectStackNative` bridge as the mobile app, and reports a platform other than `ios` or `android`.
+
+- **`ObjectStackNative.platform` widens** from `'ios' | 'android'` to `'ios' | 'android' | (string & {})`. The two known values still autocomplete. Code that switches exhaustively over the old two values now has to handle another string; branch on a capability (`has()`) or the viewport instead of the platform. The Console's root attribute `data-os-native-shell` carries the value verbatim.
+- **Bridge protocol version gate.** The bridge's `version` bumps only on a breaking change, and this package speaks version `1`. `getNativeHost()` and `useNativeHost()` now answer `null` for a bridge whose `version` is greater than `1` or not a number, so a page keeps its browser behaviour (no shell mode, no `ready`, the PWA manifest kept) instead of calling a protocol it cannot read.
+- **What `ready()` means, written down.** `ready` is posted when the Console has painted its own UI, which may be a loading fallback such as the auth guard's spinner while the session is read on a cold start. The shell lifts its loading cover on it, so the cover hands over to the Console's spinner rather than staying up through the session round-trip. Nothing about when it is posted changes.
diff --git a/ui/apps/console/src/__tests__/App.nativeShellOpenSet-29330.test.tsx b/ui/apps/console/src/__tests__/App.nativeShellOpenSet-29330.test.tsx
new file mode 100644
index 00000000000..32a0681d629
--- /dev/null
+++ b/ui/apps/console/src/__tests__/App.nativeShellOpenSet-29330.test.tsx
@@ -0,0 +1,240 @@
+/**
+ * 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 mode beyond the mobile app, as the real `App` wires it.
+ *
+ * `ObjectStackNative.platform` is an open set: the desktop shell reports a
+ * value other than `ios` or `android`, and announces only the capabilities it
+ * has. The Console must treat that host exactly like the mobile one: shell
+ * mode on, `ready` posted once, the root attribute carrying the platform
+ * verbatim, and no call to a method the shell did not announce.
+ *
+ * Two more pins sit beside it:
+ *
+ * - `ready` means "the Console has painted its own UI". On a cold start to a
+ * protected route that UI is the auth guard's loading fallback, and `ready`
+ * is posted while it is still on screen, so the shell's loading cover hands
+ * over to the Console's spinner instead of waiting out the session read.
+ * - A bridge speaking a newer protocol than this Console (`version` 2) reads
+ * as no bridge: no shell mode and no `ready`.
+ *
+ * `App` is the real module under the real `BrowserRouter`, and so are
+ * `ProtectedRoute` and `AuthGuard`; the pages and the shell chrome are stubs,
+ * as in `App.nativeShellMode-29039.test.tsx`.
+ */
+
+import { describe, expect, it, vi, afterEach, beforeEach, type Mock } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import {
+ NATIVE_HOST_GLOBAL,
+ nativeGetPushToken,
+ nativeHaptic,
+ nativeNavigate,
+ nativeOpenExternal,
+ nativeResetServer,
+ resetNativeHostEventsForTests,
+ type ObjectStackNative,
+} from '../../../../packages/mobile/src/nativeHost';
+import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost';
+
+const { passthrough, stub, authState } = vi.hoisted(() => {
+ const passthrough = ({ children }: { children?: ReactNode }) => <>{children}>;
+ const stub = (testid: string) => () =>
;
+ /** What the session read has answered so far; `isLoading` is a cold start. */
+ const authState = { isLoading: false };
+ return { passthrough, stub, authState };
+});
+
+vi.mock('@object-ui/app-shell', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ ConsoleShell: passthrough,
+ ConsoleToaster: () => null,
+ ConnectedShell: passthrough,
+ RequireOrganization: passthrough,
+ LoadingFallback: stub('auth-loading-fallback'),
+ LoadingScreen: stub('loading-screen'),
+ RedirectWithSplash: stub('redirect-with-splash'),
+ RequireAiSurface: passthrough,
+ SystemRedirect: () => null,
+ DefaultHomeLayout: passthrough,
+ DefaultHomePage: stub('home-page'),
+ DefaultOrganizationsLayout: passthrough,
+ DefaultOrganizationsPage: stub('organizations-page'),
+ DefaultOrganizationLayout: stub('organization-layout'),
+ DefaultMembersPage: stub('members-page'),
+ DefaultInvitationsPage: stub('invitations-page'),
+ DefaultSettingsPage: stub('settings-page'),
+ DefaultAcceptInvitationPage: stub('accept-invitation-page'),
+ DefaultAiChatPage: stub('ai-chat-page'),
+ StudioDesignSurface: stub('studio-design-surface'),
+ BuilderLanding: stub('builder-landing'),
+ getProductName: () => 'ObjectOS',
+ getFaviconUrl: () => '',
+}));
+
+// The real `AuthGuard` reads the real auth context. The provider is replaced
+// by one that publishes `authState`, so a test can hold the session read open.
+vi.mock('@object-ui/auth', async (importOriginal) => {
+ const actual = await importOriginal();
+ const { AuthCtx } = await import('../../../../packages/auth/src/AuthContext');
+ function AuthStateProvider({ children }: { children?: ReactNode }) {
+ // Outside any provider `useAuth` answers its signed-out defaults.
+ const signedOut = actual.useAuth();
+ return {children};
+ }
+ return { ...actual, AuthProvider: AuthStateProvider };
+});
+
+vi.mock('../AppContent', () => ({
+ AppContent: stub('app-content'),
+ ConsolePermissionsScope: ({ children }: { children?: ReactNode }) => <>{children}>,
+ ConsolePermissionsRequest: () => null,
+}));
+vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') }));
+vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') }));
+vi.mock('../components/FormPage', () => ({ FormPage: stub('form-page') }));
+vi.mock('../components/InternalFormRoute', () => ({ InternalFormRoute: stub('internal-form-route') }));
+vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null }));
+vi.mock('../pages/auth/LoginPage', () => ({ LoginPage: stub('login-page') }));
+vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: stub('register-page') }));
+vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: stub('forgot-page') }));
+vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: stub('reset-page') }));
+vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: stub('set-password-page') }));
+vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: stub('verify-email-page') }));
+vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: stub('verify-prompt-page') }));
+vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: stub('oauth-consent-page') }));
+vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: stub('device-auth-page') }));
+vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: stub('dev-master-detail') }));
+vi.mock('../dev/DevLists', () => ({ DevLists: stub('dev-lists') }));
+vi.mock('../dev/DevModal', () => ({ DevModal: stub('dev-modal') }));
+vi.mock('../dev/DevLookup', () => ({ DevLookup: stub('dev-lookup') }));
+vi.mock('../dev/DevRowActions', () => ({ DevRowActions: stub('dev-row-actions') }));
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+import { App } from '../App';
+
+type FakeBridge = ObjectStackNative & {
+ getPushToken: Mock;
+ haptic: Mock;
+ openExternal: Mock;
+ navigate: Mock;
+ resetServer: Mock;
+};
+
+/**
+ * A fake bridge recording what the Console posts. `ready` notes which of the
+ * two first screens is up when it arrives: the shell lifts its cover on it.
+ */
+function installBridge({
+ platform,
+ version = 1,
+ capabilities,
+}: {
+ platform: string;
+ version?: number;
+ capabilities: readonly string[];
+}): { bridge: FakeBridge; events: string[] } {
+ const events: string[] = [];
+ const onScreen = () =>
+ screen.queryByTestId('auth-loading-fallback')
+ ? 'auth-loading-fallback'
+ : screen.queryByTestId('login-page')
+ ? 'login-page'
+ : 'nothing';
+ const bridge: FakeBridge = {
+ version,
+ platform,
+ appVersion: '1.0.0',
+ capabilities,
+ safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
+ has: (capability: string) => capabilities.includes(capability),
+ getPushToken: vi.fn(async () => null),
+ haptic: vi.fn(async () => null),
+ openExternal: vi.fn(async () => null),
+ navigate: vi.fn(async () => null),
+ resetServer: vi.fn(async () => null),
+ ready: () => events.push(`ready:${onScreen()}`),
+ routeChanged: (route: string) => events.push(`route:${route}`),
+ };
+ (window as unknown as Record)[NATIVE_HOST_GLOBAL] = bridge;
+ return { bridge, events };
+}
+
+beforeEach(() => {
+ resetNativeHostEventsForTests();
+ resetNativeHostWatchForTests();
+ authState.isLoading = false;
+ window.history.pushState({}, '', '/login');
+});
+
+afterEach(() => {
+ cleanup();
+ delete (window as unknown as Record)[NATIVE_HOST_GLOBAL];
+ window.history.pushState({}, '', '/');
+});
+
+describe('a shell whose platform is neither ios nor android', () => {
+ it('desktop with a capability subset: shell mode on, ready once, the platform verbatim, nothing unannounced called', async () => {
+ const { bridge, events } = installBridge({ platform: 'desktop', capabilities: ['haptic', 'openExternal'] });
+ const { rerender } = render();
+ expect(await screen.findByTestId('login-page')).toBeInTheDocument();
+
+ expect(document.documentElement.getAttribute('data-os-native-shell')).toBe('desktop');
+ expect(events).toEqual(['ready:login-page', 'route:/login']);
+
+ // Every typed call, from inside the running Console: the announced ones
+ // reach the shell, the rest resolve `unavailable` without a call.
+ await expect(nativeHaptic('success')).resolves.toEqual({ status: 'ok', value: null });
+ await expect(nativeOpenExternal('https://example.com')).resolves.toEqual({ status: 'ok', value: null });
+ await expect(nativeGetPushToken()).resolves.toEqual({ status: 'unavailable' });
+ await expect(nativeNavigate('/home')).resolves.toEqual({ status: 'unavailable' });
+ await expect(nativeResetServer()).resolves.toEqual({ status: 'unavailable' });
+
+ rerender();
+ expect(events.filter((e) => e.startsWith('ready:'))).toHaveLength(1);
+ expect(bridge.haptic).toHaveBeenCalledTimes(1);
+ expect(bridge.openExternal).toHaveBeenCalledTimes(1);
+ expect(bridge.getPushToken).not.toHaveBeenCalled();
+ expect(bridge.navigate).not.toHaveBeenCalled();
+ expect(bridge.resetServer).not.toHaveBeenCalled();
+ });
+});
+
+describe('what ready means: the Console has painted its own UI', () => {
+ it('a cold start to a protected route posts ready while the auth guard still shows its loading fallback', async () => {
+ authState.isLoading = true;
+ window.history.pushState({}, '', '/');
+ const { events } = installBridge({ platform: 'ios', capabilities: [] });
+ render();
+ expect(await screen.findByTestId('auth-loading-fallback')).toBeInTheDocument();
+ // The session is still being read: the guard has not let the route through.
+ expect(screen.queryByTestId('root-landing')).not.toBeInTheDocument();
+ expect(events).toEqual(['ready:auth-loading-fallback', 'route:/']);
+ });
+});
+
+describe('a bridge speaking a newer protocol is no bridge', () => {
+ it('version 2: no shell mode, no ready, no route posts', async () => {
+ const { events } = installBridge({ platform: 'ios', version: 2, capabilities: ['haptic'] });
+ render();
+ expect(await screen.findByTestId('login-page')).toBeInTheDocument();
+ expect(document.documentElement.hasAttribute('data-os-native-shell')).toBe(false);
+ expect(events).toEqual([]);
+ await expect(nativeHaptic('success')).resolves.toEqual({ status: 'unavailable' });
+ });
+
+ it('control: version 1, the same bridge otherwise, is the shell', async () => {
+ const { events } = installBridge({ platform: 'ios', version: 1, capabilities: ['haptic'] });
+ render();
+ expect(await screen.findByTestId('login-page')).toBeInTheDocument();
+ expect(document.documentElement.getAttribute('data-os-native-shell')).toBe('ios');
+ expect(events).toEqual(['ready:login-page', 'route:/login']);
+ });
+});
diff --git a/ui/apps/console/src/__tests__/main.nativeBridgeVersion-29330.test.tsx b/ui/apps/console/src/__tests__/main.nativeBridgeVersion-29330.test.tsx
new file mode 100644
index 00000000000..ccbc072d9dc
--- /dev/null
+++ b/ui/apps/console/src/__tests__/main.nativeBridgeVersion-29330.test.tsx
@@ -0,0 +1,158 @@
+/**
+ * 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 PWA manifest at boot, as the real `main.tsx` decides it.
+ *
+ * Inside the native shell the Console drops its install manifest: the app is
+ * installed natively there. A bridge speaking a newer protocol than this
+ * Console (`version` 2) reads as no bridge, so the boot keeps the browser path
+ * and injects its generated manifest. Control: a version 1 bridge drops it,
+ * and no bridge at all keeps it.
+ *
+ * `main.tsx` runs for real; what it calls before the manifest step (runtime
+ * config, auth preflight, the language seed and catalogue) and what it renders
+ * after it are stubs, because this file is about that one decision.
+ */
+
+import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest';
+import { screen, waitFor, act } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import type { Root } from 'react-dom/client';
+import { NATIVE_HOST_GLOBAL } from '../../../../packages/mobile/src/nativeHost';
+
+const { passthrough, roots, resolveManifestIcons } = vi.hoisted(() => ({
+ passthrough: ({ children }: { children?: ReactNode }) => <>{children}>,
+ roots: [] as Root[],
+ /** The manifest's icon step; the manifest link lands once it settles. */
+ resolveManifestIcons: vi.fn(async () => []),
+}));
+
+vi.mock('react-dom/client', async (importOriginal) => {
+ const actual = await importOriginal();
+ const createRoot: typeof actual.createRoot = (container, options) => {
+ const root = actual.createRoot(container, options);
+ roots.push(root);
+ return root;
+ };
+ return { ...actual, createRoot, default: { ...actual, createRoot } };
+});
+vi.mock('../index.css', () => ({}));
+vi.mock('../App', () => ({ App: () => }));
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ I18nProvider: passthrough,
+ preloadBootstrapLocale: async () => undefined,
+}));
+vi.mock('@object-ui/mobile', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ MobileProvider: passthrough,
+}));
+vi.mock('@object-ui/components', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ registerPlaceholders: () => undefined,
+}));
+vi.mock('@object-ui/app-shell', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ initSentry: async () => undefined,
+ initRuntimeConfig: async () => undefined,
+ getProductName: () => 'ObjectOS',
+ getProductShortName: () => 'ObjectOS',
+ getFaviconUrl: () => '',
+ getPwaDescription: () => '',
+ getPwaThemeColor: () => '#000000',
+}));
+vi.mock('../loadLanguage', () => ({ loadLanguage: async () => ({}) }));
+vi.mock('../loadLocales', () => ({ loadLocales: async () => [] }));
+vi.mock('../languageSeed', () => ({ seedTenantLanguage: async () => undefined }));
+vi.mock('../pwaManifestIcons', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ resolveManifestIcons,
+}));
+vi.mock('../lib/auth-preflight', () => ({ preflightAuth: async () => undefined }));
+vi.mock('../register-plugins', () => ({}));
+vi.mock('../registerDeveloperComponents', () => ({}));
+vi.mock('../registerStudioComponents', () => ({}));
+vi.mock('../registerAccountComponents', () => ({}));
+vi.mock('../registerApprovalsComponents', () => ({}));
+vi.mock('../registerSystemComponents', () => ({}));
+vi.mock('../registerChatbotComponents', () => ({}));
+
+// Pay the heavy graph's first load here, at module top, so the clocked test
+// windows below measure the boot's decision and not module loading. Each case
+// then re-runs `main.tsx` itself against these already-loaded modules.
+await import('@object-ui/app-shell');
+await import('@object-ui/components');
+
+const BLOB_MANIFEST = 'blob:generated-manifest';
+
+function installBridge(version: number): void {
+ (window as unknown as Record)[NATIVE_HOST_GLOBAL] = {
+ version,
+ platform: 'ios',
+ appVersion: '2.0.0',
+ capabilities: [],
+ safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
+ has: () => false,
+ ready: () => undefined,
+ routeChanged: () => undefined,
+ };
+}
+
+/** Run `main.tsx` once, as a page load does, and wait for it to render. */
+async function boot(): Promise {
+ vi.resetModules();
+ await import('../main');
+ await waitFor(() => expect(screen.getByTestId('app')).toBeInTheDocument());
+}
+
+function manifestHrefs(): string[] {
+ return Array.from(document.querySelectorAll('link[rel="manifest"]')).map(
+ (link) => link.getAttribute('href') ?? '',
+ );
+}
+
+beforeEach(() => {
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue(BLOB_MANIFEST);
+ resolveManifestIcons.mockClear();
+});
+
+afterEach(() => {
+ act(() => {
+ for (const root of roots.splice(0)) root.unmount();
+ });
+ delete (window as unknown as Record)[NATIVE_HOST_GLOBAL];
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
+ vi.restoreAllMocks();
+});
+
+describe('the boot manifest and the bridge protocol version', () => {
+ it('a version 2 bridge is no shell: the generated manifest replaces the static one', async () => {
+ installBridge(2);
+ await boot();
+ expect(resolveManifestIcons).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(manifestHrefs()).toEqual([BLOB_MANIFEST]));
+ });
+
+ it('control: a version 1 bridge is the shell, and the manifest is dropped', async () => {
+ installBridge(1);
+ await boot();
+ // The manifest step never started, so no link can land later either.
+ expect(resolveManifestIcons).not.toHaveBeenCalled();
+ expect(manifestHrefs()).toEqual([]);
+ });
+
+ it('control: without a bridge the generated manifest replaces the static one', async () => {
+ await boot();
+ expect(resolveManifestIcons).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(manifestHrefs()).toEqual([BLOB_MANIFEST]));
+ });
+});
diff --git a/ui/packages/mobile/README.md b/ui/packages/mobile/README.md
index b00913a9c1a..5669c0f6a90 100644
--- a/ui/packages/mobile/README.md
+++ b/ui/packages/mobile/README.md
@@ -318,11 +318,22 @@ shell; the bridge contract is owned by the mobile app (`docs/HYBRID-SHELL.md`,
- `useNativeHost()` returns the bridge, or `null` outside the shell. It reads the
global lazily and re-checks at `DOMContentLoaded`, `load` and a few points after
`load`, so a bridge injected after the first render still arrives.
+- `platform` is the shell's platform string, an open set: `ios` and `android` from
+ the mobile app, another value from another shell (the desktop one). Do not branch
+ on it; branch on the viewport or on a capability.
+- This package speaks bridge protocol version `1`; the bridge's `version` bumps only
+ on a breaking change. A bridge whose `version` is higher, or not a number, reads as
+ no bridge: `null` from `useNativeHost()` and `getNativeHost()`, so the page keeps
+ its browser behaviour.
- `useNativeHostRouting({ route, navigate })` wires a router to the shell: `ready()`
once per page load after the first commit, `routeChanged(route)` once per route
change, and the shell's cancelable `objectstack-native:navigate` window event moves
the router (in-app paths only) instead of reloading the page. Outside the shell it
installs nothing.
+- `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
+ through the session round-trip.
- `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate` and
`nativeResetServer` check `has(capability)` and that the method exists before
calling. They never throw: each resolves `{ status: 'unavailable' }` without the
@@ -330,10 +341,9 @@ shell; the bridge contract is owned by the mobile app (`docs/HYBRID-SHELL.md`,
`{ status: 'failed', code, message }` when the shell refuses the call.
```tsx
-import { nativeHaptic, useNativeHost } from '@object-ui/mobile';
+import { nativeHaptic } from '@object-ui/mobile';
export function SaveButton({ onSave }: { onSave: () => Promise }) {
- const host = useNativeHost();
return (
);
}
diff --git a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx
index 22696f6cf23..b55f02a2ee8 100644
--- a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx
+++ b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx
@@ -20,6 +20,7 @@ import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { render, cleanup, act } from '@testing-library/react';
import {
+ NATIVE_BRIDGE_VERSION,
NATIVE_HOST_GLOBAL,
NATIVE_NAVIGATE_EVENT,
getNativeHost,
@@ -120,6 +121,40 @@ describe('detection', () => {
});
});
+describe('an open platform set and the bridge protocol version', () => {
+ it('a platform other than ios or android is a bridge like any other', () => {
+ const bridge = { ...fakeBridge(['haptic']), platform: 'desktop' };
+ install(bridge);
+ expect(getNativeHost()).toBe(bridge);
+ expect(getNativeHost()?.platform).toBe('desktop');
+ });
+
+ it('this package speaks protocol version 1', () => {
+ expect(NATIVE_BRIDGE_VERSION).toBe(1);
+ });
+
+ it.each([
+ ['a newer version', NATIVE_BRIDGE_VERSION + 1],
+ ['a string version', '1'],
+ ['NaN', Number.NaN],
+ ['no version', undefined],
+ ])('%s reads as no bridge: getNativeHost is null and every call is unavailable', async (_label, version) => {
+ const bridge = { ...fakeBridge(), version };
+ install(bridge);
+ expect(getNativeHost()).toBeNull();
+ await expect(nativeHaptic('light')).resolves.toEqual({ status: 'unavailable' });
+ expect(bridge.haptic).not.toHaveBeenCalled();
+ });
+
+ it('control: the version this package speaks, and an older one, are the bridge', () => {
+ for (const version of [NATIVE_BRIDGE_VERSION, 0]) {
+ const bridge = { ...fakeBridge(), version };
+ install(bridge);
+ expect(getNativeHost()).toBe(bridge);
+ }
+ });
+});
+
describe('late injection', () => {
function Probe({ onHost }: { onHost: (host: ObjectStackNative | null) => void }) {
onHost(useNativeHost());
@@ -337,6 +372,17 @@ describe('useNativeHostRouting', () => {
expect(navigate).not.toHaveBeenCalled();
});
+ it('a bridge speaking a newer protocol gets no ready, no route and no navigate listener', () => {
+ const bridge = { ...fakeBridge(), version: NATIVE_BRIDGE_VERSION + 1 };
+ install(bridge);
+ const add = vi.spyOn(window, 'addEventListener');
+ const navigate = vi.fn();
+ render();
+ expect(bridge.ready).not.toHaveBeenCalled();
+ expect(bridge.routeChanged).not.toHaveBeenCalled();
+ expect(add.mock.calls.some(([type]) => type === NATIVE_NAVIGATE_EVENT)).toBe(false);
+ });
+
it('control: outside shell mode no navigate listener is installed and nothing is posted', () => {
const add = vi.spyOn(window, 'addEventListener');
const navigate = vi.fn();
diff --git a/ui/packages/mobile/src/nativeHost.ts b/ui/packages/mobile/src/nativeHost.ts
index ea517cfbffb..467c156a331 100644
--- a/ui/packages/mobile/src/nativeHost.ts
+++ b/ui/packages/mobile/src/nativeHost.ts
@@ -23,6 +23,15 @@
/** The global name the shell injects. */
export const NATIVE_HOST_GLOBAL = 'ObjectStackNative';
+/**
+ * The bridge protocol version this Console speaks. A bridge's `version` bumps
+ * only on a breaking change, so a host announcing a higher version (or no
+ * numeric version at all) speaks a protocol this Console cannot read:
+ * `getNativeHost()` answers `null` for it, and the Console runs as it does in
+ * a browser.
+ */
+export const NATIVE_BRIDGE_VERSION = 1;
+
/**
* The window event the shell dispatches when it opens a Console route without
* reloading the page (a notification tap, an app link, a bridge `navigate`).
@@ -62,7 +71,12 @@ export type NativeHostMethod = 'getPushToken' | 'haptic' | 'openExternal' | 'nav
export interface ObjectStackNative {
/** Bridge protocol version; bumps only on a breaking change. */
readonly version: number;
- readonly platform: 'ios' | 'android';
+ /**
+ * The shell's platform string. An open set: the mobile shell reports `ios`
+ * or `android`, and another shell (the desktop one) reports its own value.
+ * Branch on a capability (`has()`) or the viewport, never on this value.
+ */
+ readonly platform: 'ios' | 'android' | (string & {});
readonly appVersion: string;
/** Feature-detect with `has()`. */
readonly capabilities: readonly string[];
@@ -78,7 +92,10 @@ export interface ObjectStackNative {
/** Forget the configured server and return to the shell's server picker. */
resetServer(): Promise;
- /** Event: the page is interactive (the shell lifts its loading cover). */
+ /**
+ * Event: the Console has painted its own UI, which may be a loading
+ * fallback (the shell lifts its loading cover on it). See `postNativeReady`.
+ */
ready(): void;
/** Event: the SPA route changed. */
routeChanged(route: string): void;
@@ -105,11 +122,19 @@ type NativeWindow = Window & { [NATIVE_HOST_GLOBAL]?: unknown };
* The bridge when the shell has injected it, otherwise `null`. Reads the
* global on every call, so a bridge injected after the first render is seen
* the next time anyone asks.
+ *
+ * A bridge whose `version` is not a number, or is greater than
+ * `NATIVE_BRIDGE_VERSION`, is also `null`: its protocol has broken this one,
+ * so the Console takes its browser path (no shell mode, no `ready`, the PWA
+ * manifest kept) rather than call methods whose meaning may have changed.
*/
export function getNativeHost(): ObjectStackNative | null {
if (typeof window === 'undefined') return null;
const host = (window as NativeWindow)[NATIVE_HOST_GLOBAL];
- return host !== null && typeof host === 'object' ? (host as ObjectStackNative) : null;
+ if (host === null || typeof host !== 'object') return null;
+ const version = 'version' in host ? host.version : undefined;
+ if (typeof version !== 'number' || !(version <= NATIVE_BRIDGE_VERSION)) return null;
+ return host as ObjectStackNative;
}
/** Whether the shell announces `capability` and the bridge carries the method. */
@@ -173,6 +198,13 @@ let lastPostedRoute: string | null = null;
/**
* Post `ready()` to the shell. Posts at most once per page load; later calls
* are no-ops. Returns whether this call posted it.
+ *
+ * `ready` means "the Console has painted its own UI", and that UI may be a
+ * loading fallback: on a cold start to a protected route it is the auth
+ * guard's spinner while the session is still being read. The shell lifts its
+ * loading cover on `ready`, so the cover hands over to the Console's own
+ * spinner. Waiting for the session instead would hold the native cover through
+ * a network round-trip.
*/
export function postNativeReady(host: ObjectStackNative | null = getNativeHost()): boolean {
if (readyPosted || !host || typeof host.ready !== 'function') return false;
diff --git a/ui/packages/mobile/src/useNativeHost.ts b/ui/packages/mobile/src/useNativeHost.ts
index a73b2e10c3d..7741feddc3d 100644
--- a/ui/packages/mobile/src/useNativeHost.ts
+++ b/ui/packages/mobile/src/useNativeHost.ts
@@ -73,7 +73,8 @@ const getServerSnapshot = (): ObjectStackNative | null => null;
* re-read at `DOMContentLoaded`, `load` and a few timed points after `load`,
* so a bridge the shell injects after the first render re-renders the caller
* with it. Without the global the hook returns `null` for good and changes
- * nothing.
+ * nothing; so does a bridge speaking a newer protocol than
+ * `NATIVE_BRIDGE_VERSION` (see `getNativeHost`).
*/
export function useNativeHost(): ObjectStackNative | null {
const host = useSyncExternalStore(subscribe, getNativeHost, getServerSnapshot);
@@ -99,7 +100,12 @@ export interface UseNativeHostRoutingOptions {
* from a component that renders beside the routes. In the shell it:
*
* - posts `ready()` once per page load, after the first commit of the router
- * tree (or as soon as a late bridge appears);
+ * tree (or as soon as a late bridge appears). That commit is the Console's
+ * own UI and may be a loading fallback, such as the auth guard's spinner
+ * while the session is read on a cold start: `ready` means "the Console has
+ * painted", not "the page's data has settled". The shell lifts its loading
+ * cover on it, handing over to that spinner instead of holding the native
+ * cover through the session round-trip;
* - posts `routeChanged(route)` on every navigation, once per route change;
* - listens for the shell's `objectstack-native:navigate` event, moves the
* router to `detail.route` when it is an in-app path, and cancels the event