Skip to content

Commit 45e9b2f

Browse files
feat(mobile): native shell mode — useNativeHost, typed bridge calls, and the Console wired to the mobile shell (#29140)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbBCu5yU2doirS4gQ2KGHPVk)_ Fixes #29039 Clause-②: yes (widening) — `@object-ui/mobile` is published and gains new exports; the seat writes the TIER_DEFAULT contract review. Metadata-protocol: none ## 维护者速读 - **改了什么**:Console 现在能识别自己被 ObjectStack 移动端外壳托管(`window.ObjectStackNative`)。`@object-ui/mobile` 新增 `useNativeHost()`、桥的类型与五个不抛错的调用封装;Console 在路由层向外壳发送 `ready()`(每次页面加载一次,首个路由渲染之后)与 `routeChanged(route)`(每次 SPA 导航一次),并接住外壳的 `objectstack-native:navigate` 事件用自己的路由跳转、不再整页重载。 - **为什么改**:移动端 2.0 是包着 Console 的原生外壳,此前 Console 不知道自己被托管:外壳只能等 WebView 的 load 事件才撤加载遮罩,通知点击会整页重载 Console。 - **风险与回滚**:低。没有这个全局对象时,监听器不安装、事件不发送、页面与今天完全一致(有测试与真实浏览器读数两面钉住)。外壳模式下唯一的界面差异是不再注入 PWA 安装 manifest。安全区内边距按席位裁决不加:外壳已经自己给 WebView 加了内边距,再加会重复。回滚即 revert 本 PR。 - **先看**:① `ui/packages/mobile/src/useNativeHost.ts`(延迟检测的复查点、路由接线、导航事件只接受站内路径);② `ui/packages/mobile/src/nativeHost.ts`(桥类型、`has()` 能力检测、`NativeCallResult`);③ `ui/apps/console/src/App.tsx` 的 `NativeShellRouting`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/mobile/src/__tests__/nativeHost.test.tsx apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx`,预期 44 个测试全绿(外壳模式 ready 只发一次、导航事件被接住、没有全局时一切照旧)。 - **需要你做的**:无,席位落地。 ## What changes The ObjectStack mobile shell (objectstack-ai/mobile#144) hosts this Console in a WebView and injects `window.ObjectStackNative`. The bridge contract is the mobile repository's `docs/HYBRID-SHELL.md`, section "Native bridge" (read at mobile PR 144 head `443207c`). **`@object-ui/mobile` (new exports only):** - `ObjectStackNative` types exactly the contract's members: `version`, `platform`, `appVersion`, `capabilities`, `safeArea`, `has(capability)`, the calls `getPushToken`, `haptic`, `openExternal`, `navigate`, `resetServer` (each a Promise), and the events `ready()` and `routeChanged(route)`. Nothing else is typed. Supporting types: `NativeHapticKind`, `NativePushToken`, `NativeSafeArea`, `NativeHostMethod`, `NativeCallResult`. - `getNativeHost()` reads the global at call time and returns it, or `null`. `useNativeHost()` is the React form. - **Lazy detection (the re-check mechanism the brief asks the PR to name).** `useNativeHost()` reads the global at render time (`useSyncExternalStore`), re-reads it once on mount, and, only while no bridge is present, re-checks at `DOMContentLoaded` (if the document is still loading), at the window `load` event, and at 100 ms, 500 ms and 2000 ms after `load`. The timed re-checks exist because the shell guarantees the bridge on Android only by evaluating its bootstrap again after the page has loaded (react-native-webview `injectedJavaScript`), which can land just after `load` fires. A bridge that appears at any of those points re-renders every caller with it. Without the global the hook returns `null` and nothing else happens. - **Typed wrappers that feature-detect and never throw:** `nativeGetPushToken()`, `nativeHaptic(kind)`, `nativeOpenExternal(url)`, `nativeNavigate(route)`, `nativeResetServer()`. Each checks `has(method)` (capability names equal method names in the contract) and that the method exists, then calls it. Each resolves a `NativeCallResult`: `{ status: 'unavailable' }` (no shell, no capability, no method, or a `has()` that throws), `{ status: 'ok', value }`, or `{ status: 'failed', code, message }` when the shell rejects the call (its error `code`: `unknown_method`, `bad_args`, `failed`). The wrappers carry a `native` prefix so the package entry does not export a bare `navigate`. - **Events.** `postNativeReady()` posts at most once per page load. `postNativeRouteChanged(route)` does not post a route equal to the one it posted last. - `useNativeHostRouting({ route, navigate })` is the router wiring: `ready()` after the first commit (or as soon as a late bridge appears), `routeChanged` once per route change, and the navigate-event listener below. Outside the shell it installs nothing. **Console wiring (`ui/apps/console/src/App.tsx`, `main.tsx`):** - `NativeShellRouting` renders beside the `Routes` element inside `ConsoleShell` (so inside its Suspense boundary) under the `BrowserRouter`. Its first effect therefore runs once the first route has committed, and that is when `ready()` is posted, not at boot. The route it posts is the router location's `pathname + search + hash`, which react-router already gives without the basename; the shell's own `routeFromConsoleUrl` keeps query and fragment too, so the route the shell recovers to is the full one. - **Maintainer's scope addition** ([comment on the card](#29039 (comment))): in shell mode the Console listens for the cancelable window event `objectstack-native:navigate`, moves its own router to `detail.route`, and calls `preventDefault()`, so a notification tap does not reload the bundle. Per the seat's ruling, `preventDefault()` is called only after the route is accepted, and only an in-app path is accepted: a string that starts with exactly one `/`, with no backslash and no control character (`//host`, `https:`, `/` + backslash + `host`, a relative path, an empty string or a non-string are left alone, so the shell's `location.assign` fallback runs). The navigation posts `routeChanged` once, through the same route-change effect. Outside shell mode the listener is not installed. - In shell mode the root element carries `data-os-native-shell="ios|android"`, so a stylesheet can tell shell mode apart. Nothing reads it yet. **Shell-mode chrome (each element hidden, with the reason):** - **The PWA install manifest.** In shell mode `main.tsx` removes the static `link rel=manifest` from `index.html` and does not inject the branded blob manifest. The app is installed natively there, so an install manifest has nothing to offer. Read after the boot round-trips; a bridge that lands even later only leaves the manifest in place, which a WebView never acts on. - Nothing else. The Console registers no service worker and draws no install prompt, so neither fires in the shell. At the shell's phone width the Console already draws its phone layout, which hides the desktop-only chrome (for example the help menu and the desktop search trigger are `hidden md:flex`). No new layout. **Safe area: a measured departure from the card's Ask 2 ("pads with the `--os-native-safe-*` variables"), ruled by the seat.** The shell already pads its WebView: `components/shell/ConsoleWebView.tsx` at mobile `443207c` wraps the WebView in a View with `paddingTop: insets.top, paddingBottom: insets.bottom`, unconditionally. Padding again in the Console would double the top and bottom insets. So the shell stays the single safe-area owner and the Console adds no padding in shell mode. The insets stay readable (`--os-native-safe-*`, `useNativeHost()?.safeArea`) for a later surface that opts in. No spec change, no metadata key, no new gate. `ui/packages/app-shell/src/layout/**` is not touched. ## Tests - New `ui/packages/mobile/src/__tests__/nativeHost.test.tsx` (40 tests): detection (absent, present, non-object global); late injection after `load` (timed re-check), at `DOMContentLoaded`, at `load`, and the control that stays `null` through every re-check; every wrapper resolves `unavailable` without a bridge, when `has()` says no (and the method is never called), and when the method is missing; `ok` with the value and the forwarded argument; `failed` with the shell's code; a throwing `has()`; `ready` once per page load; `routeChanged` once per route; `useNativeHostRouting` under StrictMode (one `ready`, one post per route), a late bridge receiving `ready` and the current route, the navigate event (in-app route cancelled and routed; six non-app shapes left alone), no listener outside shell mode, listener removed on unmount. - New `ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx` (4 tests), the real `App` under its real `BrowserRouter` with the pages stubbed as in `App.docsPortalLazy.test.tsx`: with a fake bridge installed before render, `ready` arrives once and only after the first route's page is on screen, then `routeChanged('/login')`, and the root carries `data-os-native-shell`; the navigate event moves the router by a history push (no document load) and posts the new route once; a `//host` route is left to the shell; control without the global: nothing posted, nothing marked, the event not cancelled. Ablations (each through `scripts/ablation-replace.mjs`, mutation landed by anchor count and blob hash, file restored byte-identical by hash, under `os-verify-lock`), run on the 44 tests above: - late-injection watch disabled (`watchForLateInjection` returns at once): 4 red, the three late-injection cases and the late-bridge routing case; - the `readyPosted` once-guard removed: 2 red; - `event.preventDefault()` removed from the navigate listener: 2 red (mobile and console); - the wrappers' `has()` + method check reduced to "bridge present": 11 red. ## Acceptance notes - **Real-browser reading** (Chromium via Playwright, 390 × 844, the Console's Vite dev server with no backend, so the login route shows "Cannot connect to server"; the bridge installed by an init script before any page script): - shell: `__events` = `ready@/login`, `route:/login` (ready posted once, after 19 elements had rendered under the root); root attribute `data-os-native-shell="ios"`; no manifest link; body padding `0px`. Dispatching `objectstack-native:navigate` with `/forgot-password`: `defaultPrevented` true, the page moved to `/forgot-password` with the same boot id (no reload), and `route:/forgot-password` was posted once. - browser (no global): no events, no attribute, the branded blob manifest present, the navigate event not cancelled and the page stayed on `/login`. - late injection: a bridge installed 50 ms after `load` (the dev server's `load` fired at about 7.4 s) still received `ready` and `route:/login`, and the attribute was set. - **Finding for the seat (doc mismatch, not filed here):** mobile `docs/HYBRID-SHELL.md` "Console-side integration" says the Console should "pad with the safe-area variables", while the shell pads its WebView unconditionally (`ConsoleWebView.tsx` at `443207c`). One of the two has to change before a Console that pads could ship; this PR follows the shell's behaviour. - **Noted, not changed:** an iPad (or any shell WebView 768 px wide or more) gets the Console's desktop chrome, because the phone layout follows the viewport width. The ruling keeps the phone layout as is and adds no layout; a per-flow change at that width belongs to #29040's children. - **Noted, not changed:** the contract also lists "register the push token after sign-in and unregister on sign-out". That needs a server device registry and is not in this card's Ask; `nativeGetPushToken()` is ready for it. ## Verification All at head `1205cf10` (this branch with `origin/main` `5d3667e2` merged in; the merge brought `packages/spec` contract files and a `@object-ui/components` data-table change, nothing under `ui/packages/mobile` or the console files touched here), after rebuilding the console's dependency closure: - `pnpm exec vitest run packages/mobile/` plus every `apps/console/src/__tests__/App.*.test.tsx` and the two other console suites that render `App` (`i18nAfterSignIn-12034`, `internalFormShell`), from `ui/`, `--maxWorkers=2`, under `os-verify-lock`: 14 / 14 files, 113 / 113 tests. - `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped; 4900 tests passed, 2 skipped. - `turbo run type-check --filter=@object-ui/mobile --filter=@object-ui/console`: 72 / 72 tasks, exit 0. `eslint` on every touched source and test file: 0 problems. `pnpm --dir ui run check:new-line-citations`: 0 new citations. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`, 34 commands, `check-sdui-manifest` among them): 34 / 34 exit 0, reconciled by `--ran` (34 derived, 34 run, 0 NOT-MEASURED). - `check-changeset-no-major.mjs --base origin/main` with this body as `GITHUB_EVENT_PATH`, and `check-adr-0087-registration`, `check-closing-keyword-parity` and `check-partof-closing-keyword` on this body: exit 0. - Changed lines: about 1,100 (well under 3,000). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on every touched file: 0 problems; the repo-wide run is CI's) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jT77n7bRx8GRbrHKo7 --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jT77n7bRx8GRbrHKo7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 10bfd70 commit 45e9b2f

9 files changed

Lines changed: 1068 additions & 22 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/mobile': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
feat(mobile): native shell mode — the Console detects `window.ObjectStackNative` and talks to the ObjectStack mobile shell that hosts it
7+
8+
Clause-②: yes (widening) — `@object-ui/mobile` is published and gains new exports; the seat writes the TIER_DEFAULT contract review.
9+
Metadata-protocol: none
10+
11+
The ObjectStack mobile app hosts the web Console in a WebView and injects a bridge, `window.ObjectStackNative`, before the Console's scripts run (on older Android WebViews, possibly after them). The Console now knows when it is hosted.
12+
13+
- **`@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.
14+
- **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 }`.
15+
- **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.
16+
- **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.

‎ui/apps/console/src/App.tsx‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
* with extra `<Route>` children.
1313
*/
1414

15-
import { lazy, Suspense, useMemo } from 'react';
16-
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
15+
import { lazy, Suspense, useEffect, useMemo } from 'react';
16+
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
17+
import { useNativeHostRouting } from '@object-ui/mobile';
1718
import { AuthProvider, useAuth, createAuthenticatedFetch } from '@object-ui/auth';
1819
import { UploadProvider, createObjectStackUploadAdapter } from '@object-ui/providers';
1920
import { DevMasterDetail } from './dev/DevMasterDetail';
@@ -135,6 +136,35 @@ function resolveBasename(): string {
135136

136137
const BASENAME = resolveBasename();
137138

139+
/**
140+
* Native shell mode: wires the router to the ObjectStack mobile shell when it
141+
* hosts this Console (`window.ObjectStackNative`, see `@object-ui/mobile`).
142+
*
143+
* Rendered beside `<Routes>`, inside `ConsoleShell`'s Suspense boundary, so
144+
* its first effect runs once the first route has committed, which is when
145+
* `ready()` is posted. Every navigation posts `routeChanged` with the router's
146+
* path, query and hash (the basename is already stripped), and the shell's
147+
* `objectstack-native:navigate` event moves this router instead of reloading
148+
* the page. `data-os-native-shell` on the root element names the platform, so
149+
* a stylesheet can tell shell mode apart. Without the bridge this renders
150+
* nothing and installs nothing.
151+
*/
152+
function NativeShellRouting() {
153+
const location = useLocation();
154+
const navigate = useNavigate();
155+
const host = useNativeHostRouting({
156+
route: `${location.pathname}${location.search}${location.hash}`,
157+
navigate,
158+
});
159+
const platform = host?.platform;
160+
useEffect(() => {
161+
if (!platform) return undefined;
162+
document.documentElement.setAttribute('data-os-native-shell', platform);
163+
return () => document.documentElement.removeAttribute('data-os-native-shell');
164+
}, [platform]);
165+
return null;
166+
}
167+
138168
/** Wraps `DefaultHomeLayout` so the FAB gets the signed-in user id. */
139169
function HomeRoute() {
140170
const { user } = useAuth();
@@ -211,6 +241,7 @@ export function App() {
211241
<FaviconSync />
212242
<UploadProvider adapter={uploadAdapter}>
213243
<ConsoleShell>
244+
<NativeShellRouting />
214245
<Routes>
215246
{/*
216247
* Public auth surfaces — render OUTSIDE ProtectedRoute so
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Native shell mode, as the real `App` wires it to its router.
11+
*
12+
* The ObjectStack mobile shell injects `window.ObjectStackNative` before the
13+
* Console's scripts run. With a fake bridge installed before `App` renders,
14+
* the Console must post `ready()` once, after the first route has rendered,
15+
* post `routeChanged` with the router path on every navigation, and move its
16+
* own router (no document reload) when the shell dispatches
17+
* `objectstack-native:navigate`. Without the global, every one of those is
18+
* absent: that is the control half of each case.
19+
*
20+
* `App` is the real module under the real `BrowserRouter`; the pages and the
21+
* shell chrome are stubs, as in `App.docsPortalLazy.test.tsx`, because this
22+
* file is about the router wiring, not about what the pages draw.
23+
*/
24+
25+
import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest';
26+
import { render, screen, cleanup, act } from '@testing-library/react';
27+
import type { ReactNode } from 'react';
28+
import {
29+
NATIVE_HOST_GLOBAL,
30+
NATIVE_NAVIGATE_EVENT,
31+
resetNativeHostEventsForTests,
32+
type ObjectStackNative,
33+
} from '../../../../packages/mobile/src/nativeHost';
34+
import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost';
35+
36+
const { passthrough, stub } = vi.hoisted(() => {
37+
const passthrough = ({ children }: { children?: ReactNode }) => <>{children}</>;
38+
const stub = (testid: string) => () => <div data-testid={testid} />;
39+
return { passthrough, stub };
40+
});
41+
42+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
43+
...(await importOriginal<Record<string, unknown>>()),
44+
ConsoleShell: passthrough,
45+
ConsoleToaster: () => null,
46+
LoadingScreen: stub('loading-screen'),
47+
RedirectWithSplash: stub('redirect-with-splash'),
48+
RequireAiSurface: passthrough,
49+
SystemRedirect: () => null,
50+
DefaultHomeLayout: passthrough,
51+
DefaultHomePage: stub('home-page'),
52+
DefaultOrganizationsLayout: passthrough,
53+
DefaultOrganizationsPage: stub('organizations-page'),
54+
DefaultOrganizationLayout: stub('organization-layout'),
55+
DefaultMembersPage: stub('members-page'),
56+
DefaultInvitationsPage: stub('invitations-page'),
57+
DefaultSettingsPage: stub('settings-page'),
58+
DefaultAcceptInvitationPage: stub('accept-invitation-page'),
59+
DefaultAiChatPage: stub('ai-chat-page'),
60+
StudioDesignSurface: stub('studio-design-surface'),
61+
BuilderLanding: stub('builder-landing'),
62+
getProductName: () => 'ObjectOS',
63+
getFaviconUrl: () => '',
64+
}));
65+
66+
vi.mock('@object-ui/auth', async (importOriginal) => ({
67+
...(await importOriginal<Record<string, unknown>>()),
68+
AuthProvider: passthrough,
69+
useAuth: () => ({ user: { id: 'u1' } }),
70+
}));
71+
72+
vi.mock('../AppContent', () => ({
73+
AppContent: stub('app-content'),
74+
ConsolePermissionsScope: ({ children }: { children?: ReactNode }) => <>{children}</>,
75+
}));
76+
vi.mock('../components/ProtectedRoute', () => ({ ProtectedRoute: passthrough }));
77+
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') }));
78+
vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') }));
79+
vi.mock('../components/FormPage', () => ({ FormPage: stub('form-page') }));
80+
vi.mock('../components/InternalFormRoute', () => ({ InternalFormRoute: stub('internal-form-route') }));
81+
vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null }));
82+
vi.mock('../pages/auth/LoginPage', () => ({ LoginPage: stub('login-page') }));
83+
vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: stub('register-page') }));
84+
vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: stub('forgot-page') }));
85+
vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: stub('reset-page') }));
86+
vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: stub('set-password-page') }));
87+
vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: stub('verify-email-page') }));
88+
vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: stub('verify-prompt-page') }));
89+
vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: stub('oauth-consent-page') }));
90+
vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: stub('device-auth-page') }));
91+
vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: stub('dev-master-detail') }));
92+
vi.mock('../dev/DevLists', () => ({ DevLists: stub('dev-lists') }));
93+
vi.mock('../dev/DevModal', () => ({ DevModal: stub('dev-modal') }));
94+
vi.mock('../dev/DevLookup', () => ({ DevLookup: stub('dev-lookup') }));
95+
vi.mock('../dev/DevRowActions', () => ({ DevRowActions: stub('dev-row-actions') }));
96+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
97+
98+
import { App } from '../App';
99+
100+
/** The fake bridge: the contract's members, recording what the Console posts. */
101+
function installBridge() {
102+
const events: string[] = [];
103+
const bridge: ObjectStackNative = {
104+
version: 1,
105+
platform: 'android',
106+
appVersion: '2.0.0',
107+
capabilities: ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer'],
108+
safeArea: { top: 24, bottom: 16, left: 0, right: 0 },
109+
has: () => true,
110+
getPushToken: async () => null,
111+
haptic: async () => null,
112+
openExternal: async () => null,
113+
navigate: async () => null,
114+
resetServer: async () => null,
115+
// What the page shows at the moment `ready` arrives: the shell lifts its
116+
// loading cover on it, so the first route must already be on screen.
117+
ready: () => events.push(`ready:${screen.queryByTestId('login-page') ? 'login-page' : 'nothing'}`),
118+
routeChanged: (route: string) => events.push(`route:${route}`),
119+
};
120+
(window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL] = bridge;
121+
return events;
122+
}
123+
124+
function dispatchNavigate(route: unknown): CustomEvent {
125+
const event = new CustomEvent(NATIVE_NAVIGATE_EVENT, { cancelable: true, detail: { route } });
126+
act(() => {
127+
window.dispatchEvent(event);
128+
});
129+
return event;
130+
}
131+
132+
beforeEach(() => {
133+
resetNativeHostEventsForTests();
134+
resetNativeHostWatchForTests();
135+
window.history.pushState({}, '', '/login');
136+
});
137+
138+
afterEach(() => {
139+
cleanup();
140+
delete (window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL];
141+
window.history.pushState({}, '', '/');
142+
});
143+
144+
describe('the Console in the mobile shell', () => {
145+
it('posts ready once, after the first route rendered, and the route the router is on', async () => {
146+
const events = installBridge();
147+
render(<App />);
148+
expect(await screen.findByTestId('login-page')).toBeInTheDocument();
149+
expect(events).toEqual(['ready:login-page', 'route:/login']);
150+
expect(document.documentElement.getAttribute('data-os-native-shell')).toBe('android');
151+
});
152+
153+
it('the shell navigate event moves the router without a reload, and posts that route once', async () => {
154+
const events = installBridge();
155+
render(<App />);
156+
await screen.findByTestId('login-page');
157+
const before = window.history.length;
158+
159+
const event = dispatchNavigate('/register?from=push');
160+
expect(event.defaultPrevented).toBe(true);
161+
expect(await screen.findByTestId('register-page')).toBeInTheDocument();
162+
// The router pushed a history entry: an SPA move, not a document load.
163+
expect(window.location.pathname).toBe('/register');
164+
expect(window.history.length).toBe(before + 1);
165+
expect(events).toEqual(['ready:login-page', 'route:/login', 'route:/register?from=push']);
166+
});
167+
168+
it('a route that is not an in-app path is left to the shell', async () => {
169+
const events = installBridge();
170+
render(<App />);
171+
await screen.findByTestId('login-page');
172+
const event = dispatchNavigate('//evil.example/register');
173+
expect(event.defaultPrevented).toBe(false);
174+
expect(window.location.pathname).toBe('/login');
175+
expect(events).toEqual(['ready:login-page', 'route:/login']);
176+
});
177+
});
178+
179+
describe('control: the Console in a browser behaves as today', () => {
180+
it('posts nothing, marks nothing, and leaves the navigate event alone', async () => {
181+
render(<App />);
182+
expect(await screen.findByTestId('login-page')).toBeInTheDocument();
183+
expect(document.documentElement.hasAttribute('data-os-native-shell')).toBe(false);
184+
const event = dispatchNavigate('/register');
185+
expect(event.defaultPrevented).toBe(false);
186+
expect(window.location.pathname).toBe('/login');
187+
});
188+
});

‎ui/apps/console/src/main.tsx‎

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import ReactDOM from 'react-dom/client';
99
import './index.css';
1010
import { App } from './App';
1111
import { I18nProvider, preloadBootstrapLocale } from '@object-ui/i18n';
12-
import { MobileProvider, generatePWAManifest } from '@object-ui/mobile';
12+
import { MobileProvider, generatePWAManifest, getNativeHost } from '@object-ui/mobile';
1313
import { registerPlaceholders } from '@object-ui/components';
1414
import { initSentry, initRuntimeConfig, getProductName, getProductShortName, getFaviconUrl, getPwaDescription, getPwaThemeColor } from '@object-ui/app-shell';
1515
import { loadLanguage } from './loadLanguage';
@@ -133,27 +133,34 @@ Promise.all([
133133
// Generate a dynamic PWA manifest from runtime branding and inject it.
134134
// Blob URL replaces the static /manifest.json so PWA install prompts use
135135
// the branded name, short name, description, theme color, and favicon.
136-
const manifestJson = generatePWAManifest({
137-
enabled: true,
138-
name: getProductName(),
139-
shortName: getProductShortName(),
140-
description: getPwaDescription(),
141-
themeColor: getPwaThemeColor(),
142-
backgroundColor: '#ffffff',
143-
display: 'standalone',
144-
startUrl: './',
145-
scope: './',
146-
orientation: 'any',
147-
icons: [{ src: faviconUrl || './favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
148-
});
149-
const blob = new Blob([JSON.stringify(manifestJson)], { type: 'application/json' });
150-
const manifestUrl = URL.createObjectURL(blob);
136+
// Inside the ObjectStack mobile shell (`window.ObjectStackNative`) the static
137+
// manifest is removed and none is injected: the app is installed natively
138+
// there, so an install manifest offers nothing. Read here, after the boot
139+
// round-trips, not at module load; a bridge that lands even later only
140+
// leaves the manifest in place, which a WebView never acts on.
151141
const existingManifest = document.querySelector<HTMLLinkElement>('link[rel="manifest"]');
152142
if (existingManifest) existingManifest.remove();
153-
const manifestLink = document.createElement('link');
154-
manifestLink.rel = 'manifest';
155-
manifestLink.href = manifestUrl;
156-
document.head.appendChild(manifestLink);
143+
if (!getNativeHost()) {
144+
const manifestJson = generatePWAManifest({
145+
enabled: true,
146+
name: getProductName(),
147+
shortName: getProductShortName(),
148+
description: getPwaDescription(),
149+
themeColor: getPwaThemeColor(),
150+
backgroundColor: '#ffffff',
151+
display: 'standalone',
152+
startUrl: './',
153+
scope: './',
154+
orientation: 'any',
155+
icons: [{ src: faviconUrl || './favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
156+
});
157+
const blob = new Blob([JSON.stringify(manifestJson)], { type: 'application/json' });
158+
const manifestUrl = URL.createObjectURL(blob);
159+
const manifestLink = document.createElement('link');
160+
manifestLink.rel = 'manifest';
161+
manifestLink.href = manifestUrl;
162+
document.head.appendChild(manifestLink);
163+
}
157164

158165
// The two loaders wait for this page load's session answer. Signed in, they
159166
// read `/i18n` with the session's credentials; signed out, they request

‎ui/packages/mobile/README.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Mobile optimization for Object UI — responsive hooks, gesture support, touch t
1111
- 📐 **Responsive Containers** - `ResponsiveContainer` for breakpoint-aware rendering
1212
- 🏗️ **MobileProvider** - Context provider for mobile-aware applications
1313
- 📲 **PWA Support** - Manifest generation and service worker registration
14+
- 🧩 **Native Shell Mode** - `useNativeHost` and typed calls into the ObjectStack mobile shell's bridge
1415
- ⚙️ **Configurable Breakpoints** - Customizable breakpoint definitions
1516

1617
## Installation
@@ -275,6 +276,51 @@ export const manifest = generatePWAManifest({
275276
void registerServiceWorker({ url: '/service-worker.js' });
276277
```
277278

279+
### Native shell mode
280+
281+
The ObjectStack mobile app hosts the web Console in a WebView and injects a bridge,
282+
`window.ObjectStackNative`. Its presence is how a page knows it runs inside the
283+
shell; the bridge contract is owned by the mobile app (`docs/HYBRID-SHELL.md`,
284+
"Native bridge"), and `ObjectStackNative` types exactly its members.
285+
286+
- `useNativeHost()` returns the bridge, or `null` outside the shell. It reads the
287+
global lazily and re-checks at `DOMContentLoaded`, `load` and a few points after
288+
`load`, so a bridge injected after the first render still arrives.
289+
- `useNativeHostRouting({ route, navigate })` wires a router to the shell: `ready()`
290+
once per page load after the first commit, `routeChanged(route)` once per route
291+
change, and the shell's cancelable `objectstack-native:navigate` window event moves
292+
the router (in-app paths only) instead of reloading the page. Outside the shell it
293+
installs nothing.
294+
- `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate` and
295+
`nativeResetServer` check `has(capability)` and that the method exists before
296+
calling. They never throw: each resolves `{ status: 'unavailable' }` without the
297+
capability, `{ status: 'ok', value }` on success, or
298+
`{ status: 'failed', code, message }` when the shell refuses the call.
299+
300+
```tsx
301+
import { nativeHaptic, useNativeHost } from '@object-ui/mobile';
302+
303+
export function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
304+
const host = useNativeHost();
305+
return (
306+
<button
307+
type="button"
308+
onClick={async () => {
309+
await onSave();
310+
// Resolves { status: 'unavailable' } in a browser; nothing to branch on.
311+
await nativeHaptic('success');
312+
}}
313+
>
314+
{host ? 'Save' : 'Save changes'}
315+
</button>
316+
);
317+
}
318+
```
319+
320+
The shell pads its WebView with the device safe area itself, so a page does not add
321+
padding in shell mode. The insets stay readable as the CSS variables
322+
`--os-native-safe-top|bottom|left|right` and as `useNativeHost()?.safeArea`.
323+
278324
## Links
279325

280326
- 📦 [npm package](https://www.npmjs.com/package/@object-ui/mobile)

0 commit comments

Comments
 (0)