diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 31c346079..24de567e9 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -548,6 +548,7 @@ const iosMobileCapabilitySet = new Set(iosMobileCapabilities); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; const sharedPayloadBoundaryImports = [ 'HOST_BRIDGE_AUDIO_MIME_TYPES', + 'HOST_BRIDGE_BADGE_COUNT_MAX', 'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES', 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', @@ -573,6 +574,7 @@ const forbiddenLocalPayloadBoundaryDeclarations = [ 'IMPORT_IMAGE_MAX_BYTES', 'EXPORT_AUDIO_MAX_BYTES', 'IMPORT_AUDIO_MAX_BYTES', + 'HOST_BRIDGE_BADGE_COUNT_MAX', 'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES', 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', 'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES', diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 02725dab7..5329141cf 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -15,6 +15,7 @@ import { import { afterEach, describe, expect, test, vi } from 'vitest'; import { + HOST_BRIDGE_BADGE_COUNT_MAX, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, type HostBridgeMethod, @@ -767,11 +768,13 @@ describe('handleMobileHostBridgeMessage', () => { test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => { const invalid = await send( request('app.setBadgeCount', { - count: 1.5, + count: HOST_BRIDGE_BADGE_COUNT_MAX + 1, }), ); - expect(expectFailed(invalid).error.code).toBe('invalid_request'); + const invalidError = expectFailed(invalid).error; + expect(invalidError.code).toBe('invalid_request'); + expect(invalidError.message).toContain(String(HOST_BRIDGE_BADGE_COUNT_MAX)); expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled(); setPlatformOS('android'); diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts index c05f98578..771a50816 100644 --- a/apps/mobile-shell/src/host-bridge/dispatch.ts +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -12,6 +12,7 @@ import { type ClipboardReadTextResult, type ClipboardWriteTextPayload, type HapticsImpactPayload, + HOST_BRIDGE_BADGE_COUNT_MAX, HOST_BRIDGE_VERSION, type HostBridgeError, type HostBridgeRequest, @@ -143,7 +144,9 @@ function setBadgeCount(payload: unknown) { (payload as SetBadgeCountPayload | undefined)?.count, ); if (count === null) { - throw invalidRequest('count must be an integer between 0 and 99999'); + throw invalidRequest( + `count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`, + ); } PushNotificationIOS.setApplicationIconBadgeNumber(count); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6bebfdc32..c3dbfb7b7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -35,7 +35,7 @@ - 2026-06-18 登录 / 支付能力禁伪声明:`auth.requestLogin` 和 `payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo / Tauri 壳在真实 SDK、渠道流程和后端契约落地前不得声明这些 capability,也不得把它们写入入口 URL `hostCapabilities`;两端检查脚本会拒绝伪声明,请求实际到达壳层时必须返回明确 `unsupported_method` 并让 H5 fallback,两端壳测试直接覆盖这两个 method。 - 2026-06-18 移动壳触觉反馈边界:`haptics.impact` 只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`;未知值必须返回 `invalid_request`,不得静默降级成真实设备触觉反馈。桌面壳不声明该 capability,H5 继续按 HostBridge fallback 处理。 - 2026-06-18 分享卡图片导出:新增 `file.exportImage` HostBridge capability,H5 分享卡下载在 native app 中优先把 canvas 生成的 base64 图片交给宿主导出;Expo 壳写缓存图片后交给系统分享 / 保存面板,Tauri 壳通过系统保存对话框写入图片字节。该能力只接受 `image/png` / `image/jpeg` / `image/webp`、单次 5 MiB 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。 -- 2026-06-18 应用角标能力:新增 `app.setBadgeCount` HostBridge capability,H5 只传 `0-99999` 整数并在宿主未声明时静默 fallback;Expo 壳只在 iOS 声明并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明、不伪造成功;Tauri 壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回真实错误。 +- 2026-06-18 应用角标能力:新增 `app.setBadgeCount` HostBridge capability,H5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数并在宿主未声明时静默 fallback;Expo 壳只在 iOS 声明并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明、不伪造成功;Tauri 壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回真实错误。 - 2026-06-18 草稿生成未读角标:平台壳层把“可见作品架里未读的草稿生成完成更新”同步到 `app.setBadgeCount`;同一草稿的 work/profile/session 等多个恢复 ID 只计 1,已读、失败、生成中和不可见草稿不计入。该角标只消费已有 HostBridge 能力,宿主不支持或设置失败不影响 H5 红点、作品架或后端状态。 - 2026-06-18 宿主外观只读查询:新增 `appearance.getColorScheme` HostBridge capability,Expo 壳通过 React Native `Appearance.getColorScheme()` 读取系统配色,Tauri 壳通过主窗口 `theme()` 读取窗口主题;该能力只返回 `light` / `dark` / `unknown`,不设置 H5 主题、不覆盖系统主题,也不作为强制 UI 样式入口。 - 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capability,Expo 壳通过 React Native `AppState` 派发 `active` / `inactive` / `background`,Tauri 壳通过主窗口 focus / blur、托盘隐藏 / 恢复和页面加载重放派发统一状态;桌面隐藏到托盘或最小化都归一为 `background`,`hidden`、`minimized`、`focused`、`blurred` 只进入 `nativeState` 便于排障,不扩展共享 `state`。两端都声明 `host.events` 表示事件通过 HostBridge message 注入,但不把它作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只通过 `subscribeHostAppLifecycle()` 订阅统一状态,后续游戏循环、音频和轮询暂停 / 恢复不得直接依赖 Expo / Tauri 平台细节。 @@ -2595,3 +2595,10 @@ - 决策:移动壳 `MOBILE_SHELL_HOST_VERSION` 必须通过移动壳 `app.json` 的 Expo `version` 配置解析,异常配置只回退到与 `app.json` / `package.json` 一致的受检 fallback。不新增 `expo-constants`、OTA 更新、渠道分发、应用安装信息业务或发布通道 SDK;配置检查拒绝 `MOBILE_SHELL_HOST_VERSION` 重新写死字符串。 - 影响范围:`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:test`、`npm run mobile-shell:typecheck`、`npm run mobile-shell:config`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 移动壳角标上限共享契约来源 + +- 背景:`app.setBadgeCount` 的数量上限已经由共享 HostBridge 契约声明,但移动壳 iOS 角标错误文案仍可能手写边界数字,后续调整上限时会让壳层提示与契约漂移。 +- 决策:Expo 移动壳 `app.setBadgeCount` 的校验和错误文案都必须消费 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_BADGE_COUNT_MAX`;配置检查拒绝移动壳本地重声明角标上限。 +- 影响范围:`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/bridge.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/bridge.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 2d4feb663..6245ea7b9 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -369,7 +369,7 @@ GameBridge 禁止: - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。 -当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur、窗口 resize 后的状态读取、托盘隐藏 / 恢复和页面加载重放注入统一状态,不开放 Tauri event 插件给前端。桌面壳只向 H5 派发共享契约里的 `active` / `inactive` / `background`,隐藏到托盘和系统最小化都归一为 `background`,`hidden`、`minimized`、`focused`、`blurred` 只写入 `nativeState` 便于排障;H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点和后台状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `