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