按宿主能力声明启用原生能力
HostBridge 契约沉淀 method 与 capability 白名单 H5 解析 hostCapabilities 并按能力调用原生桥 发布分享弹窗仅在声明 share.open 时显示系统分享 补充能力声明测试和宿主壳文档
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面,且通过 `host.events` 注入 `navigation.canGoBack` 返回栈状态事件,`share.setTarget` / `share.open` 解析统一分享目标并调用 React Native 系统分享面板,发布分享弹窗在 native_app 中通过 `share.open` 提供“系统分享”动作,失败时保留复制链接回退路径;`file.exportText` 写入 Expo 缓存文本文件后交给系统分享 / 保存面板,成功只返回文件名和字节数,`haptics.impact` 通过 Expo Haptics 承接 H5 运行时点击反馈;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;H5 复制服务在 native_app 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板,失败后再回退浏览器复制路径;H5 运行时反馈在 native_app 中优先通过 `haptics.impact` 请求真实移动端触觉,宿主不可用或 unsupported 时回退浏览器 `navigator.vibrate`;H5 主站按当前平台阶段同步 `document.title` 并通过 `app.setTitle` 请求宿主窗口标题,Tauri 壳通过主窗口 API 同步非空窗口标题,Expo 移动壳不声明该能力时静默忽略;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,将 `navigation.openNativePage` 实现为 `https://app.genarrative.world` 同源 H5 route 的主窗口受控跳转,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板;桌面 `file.exportText` 通过 Tauri dialog 插件打开系统保存对话框并由 Rust 写入文本文件,但不把 dialog / fs 插件 command 直接暴露给 H5,成功只返回文件名和字节数,用户取消返回 `cancelled`;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。
|
||||
- 2026-06-18 外链接入:H5 新增 `openHostExternalUrl()` facade,`native_app` 下会把外链归一化为允许协议的绝对 URL 后请求 `app.openExternalUrl`;ICP备案号和 RPG 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `<a>` 行为,宿主不可用或拒绝时回退浏览器外链。
|
||||
- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。
|
||||
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
|
||||
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
|
||||
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
||||
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
||||
|
||||
@@ -214,7 +214,7 @@ GameBridge 禁止:
|
||||
- HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape。
|
||||
- 壳层只接受来自允许 origin / packaged asset 的消息。
|
||||
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。
|
||||
- 能力按 `capabilities` 下发,H5 根据能力决定是否展示入口或走 fallback。
|
||||
- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;不能只凭 `native_app` 宿主类型假设能力可用。
|
||||
- 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。
|
||||
- Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。
|
||||
- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。
|
||||
@@ -241,7 +241,7 @@ GameBridge 禁止:
|
||||
- iOS / Android 深链打开作品详情、创作页和邀请码。
|
||||
- 登录和支付先 fallback 到 H5;只把能力边界跑通。
|
||||
|
||||
当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`host.events`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.openExternalUrl`、`clipboard.writeText`、`file.exportText`、`haptics.impact` 和 Android 返回键回退;其中 `share.setTarget` / `share.open` 会解析统一分享目标里的 `title`、`message`、`url`、`work`、`path` 或 `targetPath` 并调用 React Native 系统分享面板;发布分享弹窗在 `native_app` 中通过 `share.open` 提供“系统分享”动作,失败时保留复制链接回退路径;`navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面,`navigation.canGoBack` 由 WebView 导航状态变化实时注入 H5,WebView 自身拦截到外域导航时只会把 `http:`、`https:`、`mailto:`、`tel:` 交给系统,危险协议直接阻断;`app.openExternalUrl` 也只允许同一协议白名单,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开 WebView 并交给系统浏览器;`clipboard.writeText` 由 H5 复制服务优先调用并写入系统剪贴板;`file.exportText` 通过 Expo 文件系统写入缓存文本文件,再交给系统分享 / 保存面板,文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数;`haptics.impact` 通过 Expo Haptics 承接运行时轻触反馈,H5 在宿主不支持时回退到浏览器 vibration。登录和支付尚未接入渠道 SDK / 原生页面时明确返回 unsupported,让 H5 fallback 承接。
|
||||
当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`host.events`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.openExternalUrl`、`clipboard.writeText`、`file.exportText`、`haptics.impact` 和 Android 返回键回退;H5 会解析并过滤 `hostCapabilities`,只对声明能力展示入口或调用宿主能力;其中 `share.setTarget` / `share.open` 会解析统一分享目标里的 `title`、`message`、`url`、`work`、`path` 或 `targetPath` 并调用 React Native 系统分享面板;发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作,失败时保留复制链接回退路径;`navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面,`navigation.canGoBack` 由 WebView 导航状态变化实时注入 H5,WebView 自身拦截到外域导航时只会把 `http:`、`https:`、`mailto:`、`tel:` 交给系统,危险协议直接阻断;`app.openExternalUrl` 也只允许同一协议白名单,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开 WebView 并交给系统浏览器;`clipboard.writeText` 由 H5 复制服务优先调用并写入系统剪贴板;`file.exportText` 通过 Expo 文件系统写入缓存文本文件,再交给系统分享 / 保存面板,文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数;`haptics.impact` 通过 Expo Haptics 承接运行时轻触反馈,H5 在宿主不支持时回退到浏览器 vibration。登录和支付尚未接入渠道 SDK / 原生页面时明确返回 unsupported,让 H5 fallback 承接。
|
||||
|
||||
### Phase 3:Tauri 桌面壳 MVP
|
||||
|
||||
|
||||
@@ -39,11 +39,11 @@ AI H5 sandbox
|
||||
|
||||
## 首批能力
|
||||
|
||||
- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`。
|
||||
- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;未知能力会被丢弃,H5 业务只根据已声明能力展示入口或发起宿主请求。
|
||||
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
|
||||
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
|
||||
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
|
||||
- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗在 `native_app` 中展示“系统分享”,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,Tauri 桌面壳把分享文本写入系统剪贴板,宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。
|
||||
- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗只在 `hostCapabilities` 声明 `share.open` 时展示“系统分享”,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,Tauri 桌面壳把分享文本写入系统剪贴板,宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。
|
||||
- `openHostShareGrid()`:微信小程序九宫格切图页。
|
||||
- `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。
|
||||
- `requestHostHapticsImpact()`:原生 App 宿主的受控触觉反馈入口。Expo 移动壳通过 `haptics.impact` 调用 Expo Haptics;H5 运行时点击反馈在 `native_app` 中优先请求宿主触觉,宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 `navigator.vibrate`。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
isHostBridgeCapability,
|
||||
normalizeHostBridgeExportFileName,
|
||||
normalizeHostBridgeExternalUrl,
|
||||
} from './hostBridge';
|
||||
@@ -42,4 +43,11 @@ describe('HostBridge shared contract helpers', () => {
|
||||
120,
|
||||
);
|
||||
});
|
||||
|
||||
test('识别 HostBridge 能力白名单', () => {
|
||||
expect(isHostBridgeCapability('share.open')).toBe(true);
|
||||
expect(isHostBridgeCapability('navigation.canGoBack')).toBe(true);
|
||||
expect(isHostBridgeCapability('unknown.capability')).toBe(false);
|
||||
expect(isHostBridgeCapability(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,23 +13,38 @@ export type NativeHostPlatform =
|
||||
| 'linux'
|
||||
| 'unknown';
|
||||
|
||||
export type HostBridgeMethod =
|
||||
| 'host.getRuntime'
|
||||
| 'auth.requestLogin'
|
||||
| 'payment.request'
|
||||
| 'share.setTarget'
|
||||
| 'share.open'
|
||||
| 'navigation.openNativePage'
|
||||
| 'app.openExternalUrl'
|
||||
| 'app.setTitle'
|
||||
| 'clipboard.writeText'
|
||||
| 'file.exportText'
|
||||
| 'haptics.impact';
|
||||
export const HOST_BRIDGE_METHODS = [
|
||||
'host.getRuntime',
|
||||
'auth.requestLogin',
|
||||
'payment.request',
|
||||
'share.setTarget',
|
||||
'share.open',
|
||||
'navigation.openNativePage',
|
||||
'app.openExternalUrl',
|
||||
'app.setTitle',
|
||||
'clipboard.writeText',
|
||||
'file.exportText',
|
||||
'haptics.impact',
|
||||
] as const;
|
||||
|
||||
export type HostBridgeCapability =
|
||||
| HostBridgeMethod
|
||||
| 'host.events'
|
||||
| 'navigation.canGoBack';
|
||||
export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number];
|
||||
|
||||
export const HOST_BRIDGE_CAPABILITIES = [
|
||||
...HOST_BRIDGE_METHODS,
|
||||
'host.events',
|
||||
'navigation.canGoBack',
|
||||
] as const;
|
||||
|
||||
export type HostBridgeCapability = (typeof HOST_BRIDGE_CAPABILITIES)[number];
|
||||
|
||||
export function isHostBridgeCapability(
|
||||
value: unknown,
|
||||
): value is HostBridgeCapability {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
HOST_BRIDGE_CAPABILITIES.includes(value as HostBridgeCapability)
|
||||
);
|
||||
}
|
||||
|
||||
export type HostBridgeRuntimeResult = {
|
||||
shell: NativeHostShell;
|
||||
|
||||
@@ -77,6 +77,10 @@ const hostBridgeMocks = vi.hoisted(() => ({
|
||||
kind: 'browser',
|
||||
clientType: null as string | null,
|
||||
clientRuntime: null as string | null,
|
||||
hostShell: null as string | null,
|
||||
hostPlatform: null as string | null,
|
||||
hostVersion: null as string | null,
|
||||
hostCapabilities: [],
|
||||
miniProgramEnv: null as string | null,
|
||||
})),
|
||||
requestHostLogin: vi.fn(),
|
||||
@@ -178,6 +182,10 @@ beforeEach(() => {
|
||||
kind: 'browser',
|
||||
clientType: null,
|
||||
clientRuntime: null,
|
||||
hostShell: null,
|
||||
hostPlatform: null,
|
||||
hostVersion: null,
|
||||
hostCapabilities: [],
|
||||
miniProgramEnv: null,
|
||||
});
|
||||
hostBridgeMocks.requestHostLogin.mockResolvedValue(true);
|
||||
@@ -463,6 +471,10 @@ test('auth gate uses mini program auth bridge instead of opening login modal in
|
||||
kind: 'wechat_mini_program',
|
||||
clientType: null,
|
||||
clientRuntime: 'wechat_mini_program',
|
||||
hostShell: null,
|
||||
hostPlatform: null,
|
||||
hostVersion: null,
|
||||
hostCapabilities: [],
|
||||
miniProgramEnv: null,
|
||||
});
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('PublishShareModal', () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
||||
'/?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open',
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
@@ -206,4 +206,18 @@ describe('PublishShareModal', () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('does not show native share action when native app shell does not declare share capability', () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
||||
);
|
||||
|
||||
render(<PublishShareModal open payload={payload} onClose={() => {}} />);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
|
||||
expect(within(dialog).queryByRole('button', { name: '系统分享' })).toBeNull();
|
||||
expect(within(dialog).getByRole('button', { name: '复制链接' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import {
|
||||
canUseHostShareGrid,
|
||||
canUseNativeHostCapability,
|
||||
getHostRuntime,
|
||||
openHostShare,
|
||||
openHostShareGrid,
|
||||
@@ -78,7 +79,7 @@ export function PublishShareModal({
|
||||
const workTypeLabel = resolvePayloadWorkTypeLabel(payload);
|
||||
const showMiniProgramGridButton =
|
||||
canUseHostShareGrid() && Boolean(coverImageSrc);
|
||||
const showNativeShareButton = hostRuntimeKind === 'native_app';
|
||||
const showNativeShareButton = canUseNativeHostCapability('share.open');
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('../../services/host-bridge/hostBridge', () => ({
|
||||
hostShell: null,
|
||||
hostPlatform: null,
|
||||
hostVersion: null,
|
||||
hostCapabilities: [],
|
||||
miniProgramEnv: null,
|
||||
})),
|
||||
openHostExternalUrl: vi.fn(async () => false),
|
||||
@@ -47,6 +48,7 @@ beforeEach(() => {
|
||||
hostShell: null,
|
||||
hostPlatform: null,
|
||||
hostVersion: null,
|
||||
hostCapabilities: [],
|
||||
miniProgramEnv: null,
|
||||
});
|
||||
vi.mocked(openHostExternalUrl).mockResolvedValue(false);
|
||||
@@ -129,6 +131,7 @@ describe('PlatformProfilePrimitives', () => {
|
||||
hostShell: 'expo_mobile',
|
||||
hostPlatform: 'ios',
|
||||
hostVersion: '0.1.0',
|
||||
hostCapabilities: ['app.openExternalUrl'],
|
||||
miniProgramEnv: null,
|
||||
});
|
||||
vi.mocked(openHostExternalUrl).mockResolvedValue(true);
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock('../../services/host-bridge/hostBridge', () => ({
|
||||
hostShell: null,
|
||||
hostPlatform: null,
|
||||
hostVersion: null,
|
||||
hostCapabilities: [],
|
||||
miniProgramEnv: null,
|
||||
})),
|
||||
openHostExternalUrl: vi.fn(async () => false),
|
||||
@@ -37,6 +38,7 @@ beforeEach(() => {
|
||||
hostShell: null,
|
||||
hostPlatform: null,
|
||||
hostVersion: null,
|
||||
hostCapabilities: [],
|
||||
miniProgramEnv: null,
|
||||
});
|
||||
vi.mocked(openHostExternalUrl).mockResolvedValue(false);
|
||||
@@ -214,6 +216,7 @@ test('RPG asset debug panel 在原生 App 中通过宿主打开原图外链', as
|
||||
hostShell: 'tauri_desktop',
|
||||
hostPlatform: 'linux',
|
||||
hostVersion: '0.1.0',
|
||||
hostCapabilities: ['app.openExternalUrl'],
|
||||
miniProgramEnv: null,
|
||||
});
|
||||
vi.mocked(openHostExternalUrl).mockResolvedValue(true);
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { HostBridgeCapability } from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import {
|
||||
canUseHostShareGrid,
|
||||
canUseNativeHostCapability,
|
||||
exportHostTextFile,
|
||||
getHostRuntime,
|
||||
getNativeAppHostRuntime,
|
||||
@@ -37,6 +39,17 @@ function asTauriInvoke(
|
||||
};
|
||||
}
|
||||
|
||||
function nativeAppPath(capabilities: HostBridgeCapability[] = []) {
|
||||
const params = new URLSearchParams({
|
||||
clientRuntime: 'native_app',
|
||||
hostShell: 'tauri_desktop',
|
||||
});
|
||||
if (capabilities.length > 0) {
|
||||
params.set('hostCapabilities', capabilities.join(','));
|
||||
}
|
||||
return `/?${params.toString()}`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
@@ -67,7 +80,7 @@ describe('hostBridge', () => {
|
||||
resolveHostRuntime({
|
||||
location: {
|
||||
search:
|
||||
'?clientRuntime=native_app&hostShell=expo_mobile&hostPlatform=ios&hostVersion=0.1.0',
|
||||
'?clientRuntime=native_app&hostShell=expo_mobile&hostPlatform=ios&hostVersion=0.1.0&hostCapabilities=share.open,unknown,clipboard.writeText',
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
@@ -75,6 +88,7 @@ describe('hostBridge', () => {
|
||||
hostShell: 'expo_mobile',
|
||||
hostPlatform: 'ios',
|
||||
hostVersion: '0.1.0',
|
||||
hostCapabilities: ['share.open', 'clipboard.writeText'],
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -93,6 +107,32 @@ describe('hostBridge', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('按宿主能力声明判断原生 App 能力是否可用', () => {
|
||||
expect(
|
||||
canUseNativeHostCapability('share.open', {
|
||||
location: {
|
||||
search:
|
||||
'?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl',
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canUseNativeHostCapability('clipboard.writeText', {
|
||||
location: {
|
||||
search:
|
||||
'?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl',
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canUseNativeHostCapability('share.open', {
|
||||
location: {
|
||||
search: '?clientRuntime=browser&hostCapabilities=share.open',
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('通过微信小程序原生页请求登录', async () => {
|
||||
const navigateTo = vi.fn((options) => {
|
||||
options.success?.();
|
||||
@@ -292,7 +332,17 @@ describe('hostBridge', () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
||||
nativeAppPath([
|
||||
'host.getRuntime',
|
||||
'navigation.openNativePage',
|
||||
'auth.requestLogin',
|
||||
'payment.request',
|
||||
'clipboard.writeText',
|
||||
'haptics.impact',
|
||||
'app.openExternalUrl',
|
||||
'app.setTitle',
|
||||
'share.open',
|
||||
]),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
@@ -397,7 +447,7 @@ describe('hostBridge', () => {
|
||||
});
|
||||
|
||||
test('原生 App 宿主不支持能力时回退到 H5 路径', async () => {
|
||||
window.history.replaceState(null, '', '/?clientRuntime=native_app');
|
||||
window.history.replaceState(null, '', nativeAppPath());
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||
@@ -476,7 +526,11 @@ describe('hostBridge', () => {
|
||||
};
|
||||
},
|
||||
);
|
||||
window.history.replaceState(null, '', '/?clientRuntime=native_app');
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['file.exportText']),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
@@ -507,7 +561,11 @@ describe('hostBridge', () => {
|
||||
});
|
||||
|
||||
test('原生 App 宿主不支持文本导出时回退 H5', async () => {
|
||||
window.history.replaceState(null, '', '/?clientRuntime=native_app');
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['file.exportText']),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(
|
||||
|
||||
@@ -2,12 +2,14 @@ import type {
|
||||
FileExportTextPayload,
|
||||
FileExportTextResult,
|
||||
HapticsImpactPayload,
|
||||
HostBridgeCapability,
|
||||
HostBridgeMethod,
|
||||
HostBridgeRuntimeResult,
|
||||
OpenExternalUrlPayload,
|
||||
ShareOpenPayload,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import {
|
||||
isHostBridgeCapability,
|
||||
normalizeHostBridgeExternalUrl,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import type {
|
||||
@@ -34,6 +36,7 @@ export type HostRuntimeSnapshot = {
|
||||
hostShell: string | null;
|
||||
hostPlatform: string | null;
|
||||
hostVersion: string | null;
|
||||
hostCapabilities: HostBridgeCapability[];
|
||||
miniProgramEnv: string | null;
|
||||
};
|
||||
|
||||
@@ -103,6 +106,17 @@ async function requestNativeHostBoolean(
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeHasNativeCapability(
|
||||
capability: HostBridgeCapability,
|
||||
context: HostRuntimeContext = {},
|
||||
) {
|
||||
const runtime = getHostRuntime(context);
|
||||
return (
|
||||
runtime.kind === 'native_app' &&
|
||||
runtime.hostCapabilities.includes(capability)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLocation(context: HostRuntimeContext) {
|
||||
return (
|
||||
context.location ?? (typeof window !== 'undefined' ? window.location : null)
|
||||
@@ -157,6 +171,10 @@ export function resolveHostRuntime(
|
||||
const hostShell = params.get('hostShell');
|
||||
const hostPlatform = params.get('hostPlatform');
|
||||
const hostVersion = params.get('hostVersion');
|
||||
const hostCapabilities = (params.get('hostCapabilities') ?? '')
|
||||
.split(',')
|
||||
.map((capability) => capability.trim())
|
||||
.filter(isHostBridgeCapability);
|
||||
const miniProgramEnv = params.get('miniProgramEnv');
|
||||
const navigatorLike = resolveNavigator(context);
|
||||
const wxBridge = resolveWxBridge(context);
|
||||
@@ -176,6 +194,7 @@ export function resolveHostRuntime(
|
||||
hostShell,
|
||||
hostPlatform,
|
||||
hostVersion,
|
||||
hostCapabilities,
|
||||
miniProgramEnv,
|
||||
};
|
||||
}
|
||||
@@ -193,6 +212,7 @@ export function resolveHostRuntime(
|
||||
hostShell,
|
||||
hostPlatform,
|
||||
hostVersion,
|
||||
hostCapabilities,
|
||||
miniProgramEnv,
|
||||
};
|
||||
}
|
||||
@@ -204,6 +224,7 @@ export function resolveHostRuntime(
|
||||
hostShell,
|
||||
hostPlatform,
|
||||
hostVersion,
|
||||
hostCapabilities,
|
||||
miniProgramEnv,
|
||||
};
|
||||
}
|
||||
@@ -222,6 +243,13 @@ export function isNativeAppRuntime(context: HostRuntimeContext = {}) {
|
||||
return resolveHostRuntime(context).kind === 'native_app';
|
||||
}
|
||||
|
||||
export function canUseNativeHostCapability(
|
||||
capability: HostBridgeCapability,
|
||||
context: HostRuntimeContext = {},
|
||||
) {
|
||||
return runtimeHasNativeCapability(capability, context);
|
||||
}
|
||||
|
||||
export function loadWechatMiniProgramBridge(
|
||||
errorMessage = '请在微信小程序内完成操作',
|
||||
) {
|
||||
@@ -303,6 +331,9 @@ export async function navigateHostNativePage(
|
||||
) {
|
||||
const runtime = getHostRuntime();
|
||||
if (runtime.kind === 'native_app') {
|
||||
if (!runtime.hostCapabilities.includes('navigation.openNativePage')) {
|
||||
return false;
|
||||
}
|
||||
const result = await requestNativeHostBoolean(
|
||||
'navigation.openNativePage',
|
||||
{ url },
|
||||
@@ -325,6 +356,9 @@ export async function navigateHostNativePage(
|
||||
|
||||
export async function requestHostLogin() {
|
||||
if (getHostRuntime().kind === 'native_app') {
|
||||
if (!canUseNativeHostCapability('auth.requestLogin')) {
|
||||
return false;
|
||||
}
|
||||
return await requestNativeHostBoolean('auth.requestLogin');
|
||||
}
|
||||
|
||||
@@ -343,6 +377,9 @@ export async function requestHostPayment({
|
||||
}: HostPaymentRequest) {
|
||||
const runtime = getHostRuntime();
|
||||
if (runtime.kind === 'native_app') {
|
||||
if (!runtime.hostCapabilities.includes('payment.request')) {
|
||||
return false;
|
||||
}
|
||||
return await requestNativeHostBoolean('payment.request', {
|
||||
payload,
|
||||
orderId,
|
||||
@@ -452,6 +489,9 @@ export async function openWechatMiniProgramShareGridPage(
|
||||
|
||||
export function setHostShareTarget(message: unknown) {
|
||||
if (getHostRuntime().kind === 'native_app') {
|
||||
if (!canUseNativeHostCapability('share.setTarget')) {
|
||||
return false;
|
||||
}
|
||||
void requestNativeAppHostBridge('share.setTarget', {
|
||||
target: message,
|
||||
}).catch(() => {
|
||||
@@ -475,7 +515,7 @@ export function setHostShareTarget(message: unknown) {
|
||||
}
|
||||
|
||||
export async function openHostShare(params: HostShareOpenRequest) {
|
||||
if (getHostRuntime().kind !== 'native_app') {
|
||||
if (!canUseNativeHostCapability('share.open')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -487,7 +527,7 @@ export async function openHostShare(params: HostShareOpenRequest) {
|
||||
}
|
||||
|
||||
export async function openHostExternalUrl({ url }: HostExternalUrlRequest) {
|
||||
if (getHostRuntime().kind !== 'native_app') {
|
||||
if (!canUseNativeHostCapability('app.openExternalUrl')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -510,7 +550,7 @@ export function postWechatMiniProgramMessage(message: unknown) {
|
||||
}
|
||||
|
||||
export async function getNativeAppHostRuntime() {
|
||||
if (getHostRuntime().kind !== 'native_app') {
|
||||
if (!canUseNativeHostCapability('host.getRuntime')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -522,7 +562,7 @@ export async function getNativeAppHostRuntime() {
|
||||
export async function writeHostClipboardText({
|
||||
text,
|
||||
}: HostClipboardWriteTextRequest) {
|
||||
if (getHostRuntime().kind !== 'native_app') {
|
||||
if (!canUseNativeHostCapability('clipboard.writeText')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -536,7 +576,7 @@ export async function writeHostClipboardText({
|
||||
export async function exportHostTextFile(
|
||||
params: HostFileExportTextRequest,
|
||||
) {
|
||||
if (getHostRuntime().kind !== 'native_app') {
|
||||
if (!canUseNativeHostCapability('file.exportText')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -557,7 +597,7 @@ export async function exportHostTextFile(
|
||||
export async function requestHostHapticsImpact(
|
||||
params: HostHapticsImpactRequest = {},
|
||||
) {
|
||||
if (getHostRuntime().kind !== 'native_app') {
|
||||
if (!canUseNativeHostCapability('haptics.impact')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -570,7 +610,7 @@ export async function requestHostHapticsImpact(
|
||||
|
||||
export async function setHostAppTitle({ title }: HostAppTitleRequest) {
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle || getHostRuntime().kind !== 'native_app') {
|
||||
if (!normalizedTitle || !canUseNativeHostCapability('app.setTitle')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,11 @@ describe('runtimeAudioFeedback', () => {
|
||||
configurable: true,
|
||||
value: vibrate,
|
||||
});
|
||||
window.history.replaceState(null, '', '/?clientRuntime=native_app');
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostCapabilities=haptics.impact',
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
|
||||
Reference in New Issue
Block a user