按宿主能力声明启用原生能力

HostBridge 契约沉淀 method 与 capability 白名单

H5 解析 hostCapabilities 并按能力调用原生桥

发布分享弹窗仅在声明 share.open 时显示系统分享

补充能力声明测试和宿主壳文档
This commit is contained in:
2026-06-18 00:48:13 +08:00
parent ee49c26868
commit 38ed2227d3
13 changed files with 194 additions and 35 deletions

View File

@@ -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固定玩法在各宿主中读取同一作品数据和运行态 snapshotAI sandbox 无法直接调用 HostBridgeTauri release 不允许任意远端页面调用桌面命令。
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md``docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`

View File

@@ -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 导航状态变化实时注入 H5WebView 自身拦截到外域导航时只会把 `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 导航状态变化实时注入 H5WebView 自身拦截到外域导航时只会把 `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 3Tauri 桌面壳 MVP

View File

@@ -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 HapticsH5 运行时点击反馈在 `native_app` 中优先请求宿主触觉,宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 `navigator.vibrate`

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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({

View File

@@ -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();
});
});

View File

@@ -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(
() => () => {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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),