补齐平台壳宿主消费门禁
新增平台壳草稿通知和角标 HostBridge 同步层 补充真实 Tauri transport 单测和跳一跳完成集成测试 扩展原生壳门禁覆盖 H5 宿主消费链路 更新宿主壳协议、方案和共享决策记录
This commit is contained in:
@@ -68,7 +68,8 @@
|
||||
- 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()`,`useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `<audio>` / WebAudio,回到 `active + focused` 后仅在运行态仍在播放、音源存在且用户音乐音量大于 0 时恢复,不改变用户音量设置。
|
||||
- 2026-06-18 本地通知能力:新增 `notification.showLocal` HostBridge capability,H5 只能传必填 `title` 和可选 `body`,共享契约负责修剪、折叠普通空白、限制长度并拒绝控制字符;Expo 壳通过 `expo-notifications` 请求系统通知权限、创建 Android 本地 channel 并发送即时本地通知,Tauri 壳通过 Rust 侧 `tauri-plugin-notification` 发送系统通知且不开放插件 JS guest API。该能力不包含远程推送、token 注册、定时提醒或后台远程通知,权限拒绝、系统失败或宿主未声明时由 H5 视作失败并继续主流程。
|
||||
- 2026-06-18 移动壳通知权限边界:Expo 移动壳的 Android 包配置必须阻断 `RECEIVE_BOOT_COMPLETED`、`SCHEDULE_EXACT_ALARM` 和 `USE_EXACT_ALARM`,只保留即时本地通知所需权限和前台展示 handler;移动壳源码不得调用 Expo push token、设备 push token、push token listener、通知响应跳转 listener、定时 / 周期通知 API 或 `seconds` / `repeats` / `timeInterval` / `date` / `calendar` / `daily` / `weekly` / `monthly` / `yearly` 触发字段。`Notifications.scheduleNotificationAsync` 只能保留 iOS / 默认 `trigger: null` 和 Android 固定 `channelId` 的即时通知结构;配置检查和 Expo public config smoke 会拒绝这些权限或远程 / 后台 / 定时通知流程被重新打开。
|
||||
- 2026-06-18 草稿生成完成 / 失败通知:平台壳层的 `markDraftReady` / `markDraftFailed` 统一收口会在原生壳声明 `notification.showLocal` 时请求即时本地通知;通知 payload 只包含生成完成 / 失败标题和草稿来源正文,按草稿来源去重,同一草稿重新进入生成中后才允许再次通知。该能力不替代现有完成 / 错误弹窗、作品架红点、队列概览或后端状态回读,通知失败不阻断主流程。
|
||||
- 2026-06-18 草稿生成完成 / 失败通知:平台壳层的 `markDraftReady` / `markDraftFailed` 统一收口会在原生壳声明 `notification.showLocal` 时请求即时本地通知;通知 payload 只包含生成完成 / 失败标题和草稿来源正文,按草稿来源去重,同一草稿重新进入生成中后才允许再次通知。该能力不替代现有完成 / 错误弹窗、作品架红点、队列概览或后端状态回读,通知失败不阻断主流程。根级原生壳门禁必须覆盖平台壳同步层通过真实 HostBridge transport 发出 `notification.showLocal`,避免只测模型文案。
|
||||
- 2026-06-19 草稿生成 HostBridge 消费门禁:`PlatformEntryFlowShellImpl` 只负责派生草稿通知和未读数量,实际宿主同步经 `platformHostBridgeSync.ts` 调用 `showHostLocalNotification` / `setHostAppBadgeCount`;`npm run check:native-shells` 必须运行该同步层的真实 Tauri transport 测试,并继续覆盖通知模型、未读计数模型、音频导入和文档导入等 H5 HostBridge 消费测试。
|
||||
- 2026-06-18 剪贴板读取能力:新增 `clipboard.readText` HostBridge capability,H5 只能读取纯文本结果,契约限制返回文本最多 100000 字符;Expo 壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 壳通过 Rust 侧 `tauri-plugin-clipboard-manager` 读取文本且不开放插件 JS guest API。该能力不读取图片、HTML、文件列表或剪贴板监听事件,宿主未声明或读取失败时由 H5 视作失败并保留原流程。
|
||||
- 2026-06-18 文本文件导入能力:新增 `file.importText` HostBridge capability,H5 统一通过 `importHostTextFile()` 读取宿主返回的纯文本内容;Expo 壳通过 `expo-document-picker` 打开系统文档选择器,Tauri 壳通过系统文件选择框读取真实文本文件。两端只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备 URI / 本机绝对路径,也不开放通用文件系统。
|
||||
- 2026-06-18 Tauri 系统托盘:桌面壳启用真实 OS 托盘并复用品牌图标,托盘菜单只执行显示主窗口、刷新主窗口和退出应用,左键点击托盘图标恢复并聚焦主窗口;该能力归桌面壳自身,不进入 HostBridge capability,不向 H5 暴露托盘、菜单、shell 或任意窗口控制 API。托盘注册成功时主窗口关闭按钮只隐藏到托盘,必须通过托盘“退出”结束应用;托盘注册失败不得阻断主窗口启动,也不得拦截关闭,避免窗口消失后无法恢复。`check:native-shells` 和 Tauri cargo test 覆盖托盘配置、菜单动作映射和关闭策略。
|
||||
|
||||
@@ -302,11 +302,11 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Expo 壳仍只通过 `expo-clipboard` 返回纯文本,H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理;普通浏览器、小程序和未声明该能力的裁剪壳不显示粘贴动作。
|
||||
|
||||
2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Expo 壳仍只发送即时本地通知,不注册远程推送 token、不做定时提醒;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。
|
||||
2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Expo 壳仍只发送即时本地通知,不注册远程推送 token、不做定时提醒;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。根级原生壳门禁必须覆盖平台壳同步层通过真实 HostBridge transport 发出该 method,避免只保留模型文案测试。
|
||||
|
||||
2026-06-18 追加:移动壳 Android 包配置显式阻断 `RECEIVE_BOOT_COMPLETED`、`SCHEDULE_EXACT_ALARM` 和 `USE_EXACT_ALARM`,保留即时本地通知所需的普通通知权限和前台展示 handler,但不允许重启后恢复通知、精确定时提醒、远程推送 token 或通知响应跳转流程进入当前壳。配置检查会同时扫描 Expo public config 和移动壳通知源码,拒绝接入 `getExpoPushTokenAsync`、设备 push token、push token listener、通知响应 listener,以及 `timeInterval`、`date`、`calendar`、`daily`、`weekly`、`monthly`、`yearly`、`seconds`、`repeats` 等定时 / 周期触发;`Notifications.scheduleNotificationAsync` 只能保留 iOS / 默认 `trigger: null` 和 Android 固定 `channelId` 的即时通知结构。
|
||||
|
||||
2026-06-18 追加:H5 的作品架未读草稿生成完成更新开始消费 `app.setBadgeCount`。Expo 移动壳仍只在 iOS 声明该能力,Android 不声明、不伪造成功;H5 只同步可见作品架内未读完成草稿数量,同一草稿多恢复 ID 只计 1,宿主不支持或设置失败不影响 H5 红点与作品架状态。
|
||||
2026-06-18 追加:H5 的作品架未读草稿生成完成更新开始消费 `app.setBadgeCount`。Expo 移动壳仍只在 iOS 声明该能力,Android 不声明、不伪造成功;H5 只同步可见作品架内未读完成草稿数量,同一草稿多恢复 ID 只计 1,宿主不支持或设置失败不影响 H5 红点与作品架状态。根级原生壳门禁必须覆盖平台壳同步层通过真实 HostBridge transport 发出该 method,避免未读计数模型和宿主消费链路脱节。
|
||||
|
||||
2026-06-18 追加:H5 的平台外部生成队列概览开始消费 `network.status` / `network.statusChanged`。宿主未声明网络能力时继续按原逻辑轮询;宿主明确离线或不可达时暂停概览请求,恢复在线后重新刷新,不改变生成任务、作品架或后端回读事实。
|
||||
|
||||
@@ -377,9 +377,9 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Tauri 壳仍只通过 Rust 侧 clipboard-manager 返回纯文本,不开放插件 JS guest API;H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理。
|
||||
|
||||
2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Tauri 壳仍只通过 Rust 侧 notification 插件发送即时系统通知,发送前先检查 `permission_state()`,处于 prompt 状态时调用 `request_permission()` 后再复判,最终未授权时返回 `host_error`;桌面 capability 仍只授权 `allow-host-bridge-request`,不开放 notification 插件 JS guest API、远程推送、定时提醒或通知 token;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。
|
||||
2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Tauri 壳仍只通过 Rust 侧 notification 插件发送即时系统通知,发送前先检查 `permission_state()`,处于 prompt 状态时调用 `request_permission()` 后再复判,最终未授权时返回 `host_error`;桌面 capability 仍只授权 `allow-host-bridge-request`,不开放 notification 插件 JS guest API、远程推送、定时提醒或通知 token;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。根级原生壳门禁必须覆盖平台壳同步层通过真实 HostBridge transport 发出该 method,避免只保留模型文案测试。
|
||||
|
||||
2026-06-18 追加:H5 的作品架未读草稿生成完成更新开始消费 `app.setBadgeCount`。Tauri 壳仍只通过主窗口受控设置任务栏角标,不开放任意窗口或系统托盘插件 API;H5 只同步可见作品架内未读完成草稿数量,同一草稿多恢复 ID 只计 1,宿主不支持或设置失败不影响 H5 红点与作品架状态。
|
||||
2026-06-18 追加:H5 的作品架未读草稿生成完成更新开始消费 `app.setBadgeCount`。Tauri 壳仍只通过主窗口受控设置任务栏角标,不开放任意窗口或系统托盘插件 API;H5 只同步可见作品架内未读完成草稿数量,同一草稿多恢复 ID 只计 1,宿主不支持或设置失败不影响 H5 红点与作品架状态。根级原生壳门禁必须覆盖平台壳同步层通过真实 HostBridge transport 发出该 method,避免未读计数模型和宿主消费链路脱节。
|
||||
|
||||
2026-06-18 追加:H5 账号状态刷新开始消费 `app.reloadWebView`。用户登录成功、退出登录或其它身份边界变化需要整页重新初始化时,`AuthGate` 会优先请求 Tauri 主 WebViewWindow 刷新;宿主未声明或刷新失败时再回退浏览器刷新,Tauri 仍只暴露 `host_bridge_request` 这一受控命令入口。
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ AI H5 sandbox
|
||||
- `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。
|
||||
- `readHostClipboardText()`:原生 App 宿主的受控剪贴板读取入口。H5 只能读取纯文本结果,宿主返回内容会按 HostBridge 契约限制到 100000 字符;Expo 移动壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 桌面壳通过 Rust 侧 `clipboard-manager` 读取系统剪贴板文本。该能力不读取图片、HTML、文件列表或剪贴板监听事件,不把 Tauri / Expo 剪贴板插件 API 直接暴露给 H5;宿主未声明或读取失败时由 H5 视作失败并保留原流程。个人中心的邀请码和兑换码弹窗只在宿主声明 `clipboard.readText` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。
|
||||
- `requestHostHapticsImpact()`:原生 App 宿主的受控触觉反馈入口。Expo 移动壳通过 `haptics.impact` 调用 Expo Haptics,只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`,未知值返回 `invalid_request` 且不触发设备反馈;H5 运行时点击反馈在 `native_app` 中优先请求宿主触觉,宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 `navigator.vibrate`。
|
||||
- `showHostLocalNotification()`:原生 App 宿主的受控即时本地通知入口。H5 只能传必填 `title` 和可选 `body`,两者都会去除首尾空白、折叠普通空白、限制长度并拒绝控制字符;Expo 移动壳通过 `expo-notifications` 请求通知权限、创建 Android 本地通知 channel 并立刻调度本地通知,Tauri 桌面壳通过 Rust 侧 `tauri-plugin-notification` 先检查系统通知权限,处于 prompt 状态时只在 Rust 侧请求一次权限,最终授权后才发送系统通知。该能力不包含远程推送、token 注册、定时提醒、后台远程通知或任意通知插件透传,宿主未声明、权限拒绝或系统失败时由 H5 视作失败并继续主流程。当前 H5 只在现有草稿生成任务收口为完成或失败时请求即时本地通知;通知按草稿来源去重,同一草稿重新进入生成中后才允许再次通知,不改变队列状态、弹窗、作品架或后端裁决。
|
||||
- `showHostLocalNotification()`:原生 App 宿主的受控即时本地通知入口。H5 只能传必填 `title` 和可选 `body`,两者都会去除首尾空白、折叠普通空白、限制长度并拒绝控制字符;Expo 移动壳通过 `expo-notifications` 请求通知权限、创建 Android 本地通知 channel 并立刻调度本地通知,Tauri 桌面壳通过 Rust 侧 `tauri-plugin-notification` 先检查系统通知权限,处于 prompt 状态时只在 Rust 侧请求一次权限,最终授权后才发送系统通知。该能力不包含远程推送、token 注册、定时提醒、后台远程通知或任意通知插件透传,宿主未声明、权限拒绝或系统失败时由 H5 视作失败并继续主流程。当前 H5 只在现有草稿生成任务收口为完成或失败时请求即时本地通知;通知按草稿来源去重,同一草稿重新进入生成中后才允许再次通知,不改变队列状态、弹窗、作品架或后端裁决。平台壳同步层必须通过真实 `host_bridge_request` transport 测到 `notification.showLocal` 请求,不能只测模型文案或替换 facade。
|
||||
- `setHostAppTitle()`:原生 App 宿主的受控窗口标题入口。H5 主站会按当前平台阶段先同步 `document.title`,再通过 `app.setTitle` 请求宿主窗口标题同步;Tauri 桌面壳支持该能力,Expo 移动壳不声明时静默忽略。
|
||||
- `setHostAppBadgeCount()`:原生 App 宿主的受控应用角标入口。H5 只传 `0-99999` 的整数,`0` 表示清除角标;Expo 移动壳只在 iOS 声明 `app.setBadgeCount` 并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明该能力;Tauri 桌面壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回明确错误,由 H5 视作失败并继续主流程。当前 H5 只把“可见作品架里未读的草稿生成完成更新”同步为角标数,同一个草稿有多个恢复 ID 时只计 1,已读、失败、生成中和不可见草稿不计入;宿主不支持或设置失败不改变 H5 红点、作品架或后端状态。
|
||||
- `setHostAppBadgeCount()`:原生 App 宿主的受控应用角标入口。H5 只传 `0-99999` 的整数,`0` 表示清除角标;Expo 移动壳只在 iOS 声明 `app.setBadgeCount` 并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明该能力;Tauri 桌面壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回明确错误,由 H5 视作失败并继续主流程。当前 H5 只把“可见作品架里未读的草稿生成完成更新”同步为角标数,同一个草稿有多个恢复 ID 时只计 1,已读、失败、生成中和不可见草稿不计入;宿主不支持或设置失败不改变 H5 红点、作品架或后端状态。平台壳同步层必须通过真实 `host_bridge_request` transport 测到 `app.setBadgeCount` 请求,保证未读计数模型和原生壳消费链路同时被门禁覆盖。
|
||||
- `reloadHostWebView()`:原生 App 宿主的受控 WebView 刷新入口。H5 只能请求刷新当前承载主站的宿主 WebView;Expo 移动壳调用当前 `react-native-webview` 的 `reload()`,Tauri 桌面壳调用主 `WebviewWindow.reload()`。该能力不接受 payload,不开放任意 URL 导航、脚本执行、Tauri guest API 或 RN WebView ref;成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。`AuthGate` 在登录态从未登录变为已登录、或从已登录变为未登录时优先调用该能力刷新当前容器;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`。
|
||||
- `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:`、`https:`、`mailto:`、`tel:`,相对路径会先归一化到当前站点绝对 URL。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `<a>` 语义。
|
||||
- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。
|
||||
|
||||
@@ -58,6 +58,7 @@ const h5HostBridgeCallChainWrapperFiles = [
|
||||
'src/hooks/useHostLifecycleActive.ts',
|
||||
'src/hooks/useHostNetworkOnline.ts',
|
||||
'src/components/platform-entry/platformProfileHostClipboard.ts',
|
||||
'src/components/platform-entry/platformHostBridgeSync.ts',
|
||||
];
|
||||
const h5HostBridgeRequiredCallChainFiles = [
|
||||
'src/App.tsx',
|
||||
@@ -223,11 +224,18 @@ const h5HostBridgeTests = [
|
||||
'src/services/host-bridge/hostBridge.test.ts',
|
||||
'src/services/host-bridge/nativeAppHostBridge.test.ts',
|
||||
'src/App.test.tsx',
|
||||
'src/components/common/CreativeAudioInputPanel.test.tsx',
|
||||
'src/components/common/PublishShareModal.test.tsx',
|
||||
'src/components/creation-agent/CreationAgentWorkspace.test.tsx',
|
||||
'src/components/platform-entry/platformDraftGenerationShelfModel.test.ts',
|
||||
'src/components/platform-entry/platformHostBridgeSync.test.ts',
|
||||
'src/components/platform-entry/platformHostNotificationModel.test.ts',
|
||||
'src/services/runtimeAudioFeedback.test.ts',
|
||||
'src/services/clipboard.test.ts',
|
||||
'src/services/appTitle.test.ts',
|
||||
];
|
||||
const h5PlatformHostBridgeIntegrationTest =
|
||||
'native app jump hop draft completion sends host notification and badge from platform shell';
|
||||
|
||||
const wechatShellTests = [
|
||||
'miniprogram/host-bridge/protocol.test.js',
|
||||
@@ -249,6 +257,18 @@ const steps = [
|
||||
command: npmCommand,
|
||||
args: ['run', 'test', '--', ...h5HostBridgeTests],
|
||||
},
|
||||
{
|
||||
label: 'h5-platform-host-bridge-integration',
|
||||
command: npmCommand,
|
||||
args: [
|
||||
'run',
|
||||
'test',
|
||||
'--',
|
||||
'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx',
|
||||
'-t',
|
||||
h5PlatformHostBridgeIntegrationTest,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'wechat-shell-tests',
|
||||
command: npmCommand,
|
||||
|
||||
@@ -191,10 +191,6 @@ import {
|
||||
saveBabyObjectMatchDraft,
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { getExternalGenerationQueueOverview } from '../../services/external-generation';
|
||||
import {
|
||||
setHostAppBadgeCount,
|
||||
showHostLocalNotification,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import {
|
||||
jumpHopClient,
|
||||
type JumpHopGalleryCardResponse,
|
||||
@@ -553,6 +549,10 @@ import {
|
||||
type PlatformHostDraftNotification,
|
||||
sendPlatformHostDraftNotificationOnce,
|
||||
} from './platformHostNotificationModel';
|
||||
import {
|
||||
sendPlatformHostDraftNotificationToHost,
|
||||
syncPlatformHostDraftBadgeCountToHost,
|
||||
} from './platformHostBridgeSync';
|
||||
import {
|
||||
buildMatch3DProfileFromSession,
|
||||
hasMatch3DRuntimeAsset,
|
||||
@@ -2030,7 +2030,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
sendPlatformHostDraftNotificationOnce(
|
||||
sentPlatformHostDraftNotificationKeysRef.current,
|
||||
notification,
|
||||
showHostLocalNotification,
|
||||
sendPlatformHostDraftNotificationToHost,
|
||||
);
|
||||
},
|
||||
[],
|
||||
@@ -3340,7 +3340,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[draftGenerationNotices, visibleDraftNoticeKeys],
|
||||
);
|
||||
useEffect(() => {
|
||||
void setHostAppBadgeCount({ count: unreadDraftGenerationUpdateCount });
|
||||
syncPlatformHostDraftBadgeCountToHost(unreadDraftGenerationUpdateCount);
|
||||
}, [unreadDraftGenerationUpdateCount]);
|
||||
const resultViewError =
|
||||
autosaveCoordinator.customWorldAutoSaveError ??
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
HostBridgeCapability,
|
||||
HostBridgeRequest,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import { resetHostRuntimeCacheForTest } from '../../services/host-bridge/hostBridge';
|
||||
import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge';
|
||||
import {
|
||||
sendPlatformHostDraftNotificationToHost,
|
||||
syncPlatformHostDraftBadgeCountToHost,
|
||||
} from './platformHostBridgeSync';
|
||||
|
||||
function asTauriInvoke(
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
||||
) {
|
||||
return async function tauriInvoke<Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) {
|
||||
return (await invoke(command, args)) as Result;
|
||||
};
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
|
||||
function installTauriHostBridgeRecorder() {
|
||||
const requests: HostBridgeRequest[] = [];
|
||||
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: HostBridgeRequest }).request;
|
||||
requests.push(request);
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: true,
|
||||
};
|
||||
});
|
||||
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
return { invoke, requests };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
delete window.__TAURI__;
|
||||
resetNativeAppHostBridgeForTest();
|
||||
resetHostRuntimeCacheForTest();
|
||||
});
|
||||
|
||||
describe('platformHostBridgeSync', () => {
|
||||
test('平台壳草稿通知通过真实 HostBridge 请求本地系统通知', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['notification.showLocal']),
|
||||
);
|
||||
const { invoke, requests } = installTauriHostBridgeRecorder();
|
||||
|
||||
await expect(
|
||||
sendPlatformHostDraftNotificationToHost({
|
||||
title: ' 生成完成 ',
|
||||
body: ' 拼图草稿 puzzle-session-1 生成任务已完成 ',
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'notification.showLocal',
|
||||
payload: {
|
||||
title: '生成完成',
|
||||
body: '拼图草稿 puzzle-session-1 生成任务已完成',
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('平台壳草稿未读计数通过真实 HostBridge 同步应用角标', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['app.setBadgeCount']),
|
||||
);
|
||||
const { invoke, requests } = installTauriHostBridgeRecorder();
|
||||
|
||||
await expect(syncPlatformHostDraftBadgeCountToHost(3)).resolves.toBe(true);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'app.setBadgeCount',
|
||||
payload: {
|
||||
count: 3,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('宿主未声明草稿通知和角标能力时平台壳保持 H5 回退', async () => {
|
||||
window.history.replaceState(null, '', nativeAppPath());
|
||||
const { invoke } = installTauriHostBridgeRecorder();
|
||||
|
||||
await expect(
|
||||
sendPlatformHostDraftNotificationToHost({
|
||||
title: '生成完成',
|
||||
body: '拼图草稿已完成。',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(syncPlatformHostDraftBadgeCountToHost(1)).resolves.toBe(false);
|
||||
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
setHostAppBadgeCount,
|
||||
showHostLocalNotification,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import type { PlatformHostDraftNotification } from './platformHostNotificationModel';
|
||||
|
||||
type PlatformHostDraftNotificationPayload = Pick<
|
||||
PlatformHostDraftNotification,
|
||||
'title' | 'body'
|
||||
>;
|
||||
|
||||
// 中文注释:平台壳只把已派生出的通知和角标计数交给 HostBridge,不改业务事实。
|
||||
export function sendPlatformHostDraftNotificationToHost({
|
||||
title,
|
||||
body,
|
||||
}: PlatformHostDraftNotificationPayload) {
|
||||
return showHostLocalNotification({ title, body });
|
||||
}
|
||||
|
||||
export function syncPlatformHostDraftBadgeCountToHost(count: number) {
|
||||
return setHostAppBadgeCount({ count });
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import type { PublicUserSummary } from '../../../packages/shared/src/contracts/a
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type {
|
||||
HostBridgeCapability,
|
||||
HostBridgeRequest,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldWorkSummary,
|
||||
@@ -94,6 +98,8 @@ import {
|
||||
regenerateBabyObjectMatchDraftAssets,
|
||||
saveBabyObjectMatchDraft,
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { resetHostRuntimeCacheForTest } from '../../services/host-bridge/hostBridge';
|
||||
import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
||||
@@ -287,6 +293,51 @@ function queryCreationTypeButton(name: string | RegExp) {
|
||||
});
|
||||
}
|
||||
|
||||
function asTauriInvoke(
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
||||
) {
|
||||
return async function tauriInvoke<Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) {
|
||||
return (await invoke(command, args)) as Result;
|
||||
};
|
||||
}
|
||||
|
||||
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()}`;
|
||||
}
|
||||
|
||||
function installTauriHostBridgeRecorder() {
|
||||
const requests: HostBridgeRequest[] = [];
|
||||
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: HostBridgeRequest }).request;
|
||||
requests.push(request);
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: true,
|
||||
};
|
||||
});
|
||||
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
return { invoke, requests };
|
||||
}
|
||||
|
||||
async function openPuzzleFormFromCreateHub(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
@@ -3779,6 +3830,10 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
delete window.__TAURI__;
|
||||
delete window.ReactNativeWebView;
|
||||
resetNativeAppHostBridgeForTest();
|
||||
resetHostRuntimeCacheForTest();
|
||||
});
|
||||
|
||||
test('create tab shows template tabs and embeds puzzle form by default', async () => {
|
||||
@@ -9057,6 +9112,142 @@ test('completed unpublished jump hop draft opens result page without starting ru
|
||||
);
|
||||
});
|
||||
|
||||
test('native app jump hop draft completion sends host notification and badge from platform shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const sessionId = 'jump-hop-session-host-bridge-1';
|
||||
const profileId = 'jump-hop-profile-host-bridge-1';
|
||||
const work = buildMockJumpHopWork({
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-host-bridge-1',
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: sessionId,
|
||||
themeText: '桥接测试跳台',
|
||||
workTitle: '桥接测试跳台',
|
||||
workDescription: '原生壳桥接测试。',
|
||||
themeTags: ['桥接'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
draft: {
|
||||
...buildMockJumpHopWork().draft,
|
||||
profileId,
|
||||
themeText: '桥接测试跳台',
|
||||
workTitle: '桥接测试跳台',
|
||||
workDescription: '原生壳桥接测试。',
|
||||
themeTags: ['桥接'],
|
||||
stylePreset: 'minimal-blocks',
|
||||
},
|
||||
});
|
||||
const createdSession = {
|
||||
sessionId,
|
||||
ownerUserId: 'user-1',
|
||||
status: 'draft' as const,
|
||||
draft: {
|
||||
...work.draft,
|
||||
profileId,
|
||||
generationStatus: 'draft' as const,
|
||||
},
|
||||
createdAt: '2026-05-30T10:00:00.000Z',
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
};
|
||||
const readySession = {
|
||||
...createdSession,
|
||||
status: 'ready' as const,
|
||||
draft: work.draft,
|
||||
updatedAt: '2026-05-30T10:05:00.000Z',
|
||||
};
|
||||
let resolveCompile!: (value: {
|
||||
actionType: 'compile-draft';
|
||||
session: typeof readySession;
|
||||
work: JumpHopWorkProfileResponse;
|
||||
}) => void;
|
||||
vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({
|
||||
...testCreationEntryConfig,
|
||||
creationTypes: [
|
||||
...testCreationEntryConfig.creationTypes,
|
||||
{
|
||||
id: 'jump-hop',
|
||||
title: '跳一跳',
|
||||
subtitle: '主题驱动平台跳跃',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/jump-hop.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 55,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
unifiedCreationSpec: buildTestUnifiedCreationSpec('jump-hop', 5),
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(jumpHopClient.createSession).mockResolvedValue({
|
||||
session: createdSession,
|
||||
});
|
||||
vi.mocked(jumpHopClient.executeAction).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCompile = resolve;
|
||||
}),
|
||||
);
|
||||
const { requests } = installTauriHostBridgeRecorder();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['app.setBadgeCount', 'notification.showLocal']),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('跳一跳'));
|
||||
await user.type(await screen.findByLabelText('主题'), '桥接测试跳台');
|
||||
await user.click(await screen.findByRole('button', { name: '生成' }));
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '跳一跳草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
|
||||
await act(async () => {
|
||||
resolveCompile({
|
||||
actionType: 'compile-draft',
|
||||
session: readySession,
|
||||
work,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.method === 'notification.showLocal' &&
|
||||
(request.payload as { title?: string; body?: string }).title ===
|
||||
'生成完成' &&
|
||||
(request.payload as { body?: string }).body?.includes('跳一跳草稿'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.method === 'app.setBadgeCount' &&
|
||||
(request.payload as { count?: number }).count === 1,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user