From ae3cdf4e6911298a14203869d7421c8fd32b7855 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 18:00:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E8=A7=92=E6=A0=87=E7=9C=9F=E5=AE=9E=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动 iOS 角标改为请求 badge 权限后调用 Expo Notifications 角标设置以 setBadgeCountAsync 返回值作为成功依据 测试和门禁覆盖权限拒绝与系统拒绝设置失败 文档和项目记忆同步角标能力边界 --- apps/mobile-shell/scripts/check-config.mjs | 19 +- .../src/host-bridge/badge.test.ts | 211 ++++++++++++++---- apps/mobile-shell/src/host-bridge/badge.ts | 67 +++++- .../src/host-bridge/bridge.test.ts | 101 +++++++-- .../src/host-bridge/dispatch.test.ts | 3 - .../shared-memory/decision-log.md | 6 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- 8 files changed, 335 insertions(+), 78 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index d95584de6..390510f8c 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -2718,8 +2718,12 @@ for (const snippet of [ 'HOST_BRIDGE_BADGE_COUNT_MAX', 'request: HostBridgeRequest', 'normalizeHostBridgeBadgeCount', - 'PushNotificationIOS.setApplicationIconBadgeNumber(count)', - 'ok(request, true)', + 'Notifications.getPermissionsAsync', + 'Notifications.requestPermissionsAsync', + 'Notifications.setBadgeCountAsync(count)', + 'allowBadge: true', + 'app badge permission denied', + 'app badge update unavailable', 'app badge count is only supported on iOS mobile shell', ]) { if (!badgeSource.includes(snippet)) { @@ -2728,10 +2732,11 @@ for (const snippet of [ } for (const snippet of [ "test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported'", + "test('app.setBadgeCount 在 iOS 角标权限拒绝时不返回成功'", "setPlatformOS('android')", "request('app.setBadgeCount',", "expect(expectFailed(unsupported).error.code).toBe('unsupported_capability')", - 'expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled()', + 'expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled()', ]) { if (!bridgeTestSource.includes(snippet)) { throw new Error( @@ -2740,10 +2745,16 @@ for (const snippet of [ } } for (const snippet of [ - 'sets and clears the iOS app badge count through PushNotificationIOS', + 'sets and clears the iOS app badge count through Expo Notifications', + 'requests iOS badge permission before updating when it is missing', + 'rejects denied iOS badge permission before touching the system badge', + 'maps native badge update false results to a stable HostBridge error', + 'maps native badge update rejections to a stable HostBridge error', 'rejects invalid badge counts before touching the system badge', 'rejects missing badge payload before touching the system badge', 'returns unsupported on Android before validating payload or touching badge APIs', + 'allowBadge: true', + 'app badge permission denied', 'HOST_BRIDGE_BADGE_COUNT_MAX + 1', "setPlatformOS('android')", 'not.toHaveBeenCalled()', diff --git a/apps/mobile-shell/src/host-bridge/badge.test.ts b/apps/mobile-shell/src/host-bridge/badge.test.ts index f1fdda2fe..549ac88b6 100644 --- a/apps/mobile-shell/src/host-bridge/badge.test.ts +++ b/apps/mobile-shell/src/host-bridge/badge.test.ts @@ -1,4 +1,5 @@ -import { Platform, PushNotificationIOS } from 'react-native'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { @@ -9,13 +10,41 @@ import { } from '../../../../packages/shared/src/contracts/hostBridge'; import { setMobileAppBadgeCount } from './badge'; +type NotificationPermissionStatus = + Awaited>; + +const BADGE_GRANTED_PERMISSION = { + status: 'granted', + granted: true, + canAskAgain: true, + expires: 'never', + ios: { + status: 2, + allowsBadge: true, + }, +} as unknown as NotificationPermissionStatus; + +const BADGE_DENIED_PERMISSION = { + status: 'denied', + granted: false, + canAskAgain: false, + expires: 'never', + ios: { + status: 1, + allowsBadge: false, + }, +} as unknown as NotificationPermissionStatus; + +vi.mock('expo-notifications', () => ({ + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + setBadgeCountAsync: vi.fn(), +})); + vi.mock('react-native', () => ({ Platform: { OS: 'ios', }, - PushNotificationIOS: { - setApplicationIconBadgeNumber: vi.fn(), - }, })); function request(payload?: unknown): HostBridgeRequest { @@ -34,13 +63,22 @@ function setPlatformOS(os: 'ios' | 'android') { beforeEach(() => { setPlatformOS('ios'); - vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset(); + vi.mocked(Notifications.getPermissionsAsync).mockReset(); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + BADGE_GRANTED_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockReset(); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + BADGE_GRANTED_PERMISSION, + ); + vi.mocked(Notifications.setBadgeCountAsync).mockReset(); + vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(true); }); describe('mobile app badge helper', () => { - test('sets and clears the iOS app badge count through PushNotificationIOS', () => { + test('sets and clears the iOS app badge count through Expo Notifications', async () => { expect( - setMobileAppBadgeCount( + await setMobileAppBadgeCount( request({ count: 12, }), @@ -52,12 +90,11 @@ describe('mobile app badge helper', () => { ok: true, result: true, }); - expect( - PushNotificationIOS.setApplicationIconBadgeNumber, - ).toHaveBeenLastCalledWith(12); + expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(12); + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); expect( - setMobileAppBadgeCount( + await setMobileAppBadgeCount( request({ count: 0, }), @@ -66,60 +103,144 @@ describe('mobile app badge helper', () => { ok: true, result: true, }); - expect( - PushNotificationIOS.setApplicationIconBadgeNumber, - ).toHaveBeenLastCalledWith(0); + expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(0); }); - test('rejects invalid badge counts before touching the system badge', () => { - for (const count of [-1, 1.5, HOST_BRIDGE_BADGE_COUNT_MAX + 1]) { - expect(() => - setMobileAppBadgeCount( - request({ - count, - }), - ), - ).toThrowError( - `count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`, - ); - } - - expect( - PushNotificationIOS.setApplicationIconBadgeNumber, - ).not.toHaveBeenCalled(); - }); - - test('rejects missing badge payload before touching the system badge', () => { - expect(() => setMobileAppBadgeCount(request({}))).toThrowError( - `count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`, + test('requests iOS badge permission before updating when it is missing', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + BADGE_DENIED_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + BADGE_GRANTED_PERMISSION, ); - expect( - PushNotificationIOS.setApplicationIconBadgeNumber, - ).not.toHaveBeenCalled(); + await expect( + setMobileAppBadgeCount( + request({ + count: 3, + }), + ), + ).resolves.toMatchObject({ + ok: true, + result: true, + }); + + expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({ + ios: { + allowAlert: false, + allowBadge: true, + allowSound: false, + }, + }); + expect(Notifications.setBadgeCountAsync).toHaveBeenCalledWith(3); }); - test('returns unsupported on Android before validating payload or touching badge APIs', () => { - setPlatformOS('android'); + test('rejects denied iOS badge permission before touching the system badge', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + BADGE_DENIED_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + BADGE_DENIED_PERMISSION, + ); - expect(() => + await expect( setMobileAppBadgeCount( request({ count: 1, }), ), - ).toThrowError('app badge count is only supported on iOS mobile shell'); + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge permission denied', + }); - expect(() => + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + }); + + test('maps native badge update false results to a stable HostBridge error', async () => { + vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge update unavailable', + }); + }); + + test('maps native badge update rejections to a stable HostBridge error', async () => { + vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValue( + new Error('native badge failed'), + ); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge update unavailable', + }); + }); + + test('rejects invalid badge counts before touching the system badge', async () => { + for (const count of [-1, 1.5, HOST_BRIDGE_BADGE_COUNT_MAX + 1]) { + await expect( + setMobileAppBadgeCount( + request({ + count, + }), + ), + ).rejects.toThrowError( + `count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`, + ); + } + + expect( + Notifications.setBadgeCountAsync, + ).not.toHaveBeenCalled(); + expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled(); + }); + + test('rejects missing badge payload before touching the system badge', async () => { + await expect(setMobileAppBadgeCount(request({}))).rejects.toThrowError( + `count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`, + ); + + expect( + Notifications.setBadgeCountAsync, + ).not.toHaveBeenCalled(); + expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled(); + }); + + test('returns unsupported on Android before validating payload or touching badge APIs', async () => { + setPlatformOS('android'); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toThrowError('app badge count is only supported on iOS mobile shell'); + + await expect( setMobileAppBadgeCount( request({ count: HOST_BRIDGE_BADGE_COUNT_MAX + 1, }), ), - ).toThrowError('app badge count is only supported on iOS mobile shell'); + ).rejects.toThrowError('app badge count is only supported on iOS mobile shell'); expect( - PushNotificationIOS.setApplicationIconBadgeNumber, + Notifications.setBadgeCountAsync, ).not.toHaveBeenCalled(); + expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile-shell/src/host-bridge/badge.ts b/apps/mobile-shell/src/host-bridge/badge.ts index b34fbf23c..47132b967 100644 --- a/apps/mobile-shell/src/host-bridge/badge.ts +++ b/apps/mobile-shell/src/host-bridge/badge.ts @@ -1,4 +1,5 @@ -import { Platform, PushNotificationIOS } from 'react-native'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; import { HOST_BRIDGE_BADGE_COUNT_MAX, @@ -9,7 +10,52 @@ import { } from '../../../../packages/shared/src/contracts/hostBridge'; import { invalidRequest, ok } from './protocol'; -export function setMobileAppBadgeCount(request: HostBridgeRequest) { +type NotificationPermissionStatus = Awaited< + ReturnType +>; + +function hasBadgePermission(permission: NotificationPermissionStatus) { + return permission.ios?.allowsBadge === true; +} + +async function ensureMobileBadgePermission() { + let currentPermission: NotificationPermissionStatus; + try { + currentPermission = await Notifications.getPermissionsAsync(); + } catch { + throw { + code: 'host_error', + message: 'app badge permission unavailable', + } satisfies HostBridgeError; + } + if (hasBadgePermission(currentPermission)) { + return; + } + + let requestedPermission: NotificationPermissionStatus; + try { + requestedPermission = await Notifications.requestPermissionsAsync({ + ios: { + allowAlert: false, + allowBadge: true, + allowSound: false, + }, + }); + } catch { + throw { + code: 'host_error', + message: 'app badge permission unavailable', + } satisfies HostBridgeError; + } + if (!hasBadgePermission(requestedPermission)) { + throw { + code: 'host_error', + message: 'app badge permission denied', + } satisfies HostBridgeError; + } +} + +export async function setMobileAppBadgeCount(request: HostBridgeRequest) { if (Platform.OS !== 'ios') { throw { code: 'unsupported_capability', @@ -26,6 +72,21 @@ export function setMobileAppBadgeCount(request: HostBridgeRequest) { ); } - PushNotificationIOS.setApplicationIconBadgeNumber(count); + await ensureMobileBadgePermission(); + let updated = false; + try { + updated = await Notifications.setBadgeCountAsync(count); + } catch { + throw { + code: 'host_error', + message: 'app badge update unavailable', + } satisfies HostBridgeError; + } + if (!updated) { + throw { + code: 'host_error', + message: 'app badge update unavailable', + } satisfies HostBridgeError; + } return ok(request, true); } diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 749cc236f..82767516f 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -9,7 +9,6 @@ import * as Sharing from 'expo-sharing'; import { Appearance, Platform, - PushNotificationIOS, Share, } from 'react-native'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -46,6 +45,10 @@ const GRANTED_NOTIFICATION_PERMISSION = { granted: true, canAskAgain: true, expires: 'never', + ios: { + status: 2, + allowsBadge: true, + }, } as NotificationPermissionStatus; const DENIED_NOTIFICATION_PERMISSION = { @@ -53,6 +56,10 @@ const DENIED_NOTIFICATION_PERMISSION = { granted: false, canAskAgain: false, expires: 'never', + ios: { + status: 1, + allowsBadge: false, + }, } as NotificationPermissionStatus; let requestSequence = 0; @@ -195,6 +202,7 @@ vi.mock('expo-notifications', () => ({ getPermissionsAsync: vi.fn(), requestPermissionsAsync: vi.fn(), scheduleNotificationAsync: vi.fn(), + setBadgeCountAsync: vi.fn(), setNotificationChannelAsync: vi.fn(), setNotificationHandler: vi.fn(), })); @@ -211,9 +219,6 @@ vi.mock('react-native', () => ({ Platform: { OS: 'ios', }, - PushNotificationIOS: { - setApplicationIconBadgeNumber: vi.fn(), - }, Share: { share: vi.fn(), }, @@ -343,7 +348,8 @@ afterEach(() => { ); vi.mocked(Notifications.setNotificationChannelAsync).mockReset(); vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null); - vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset(); + vi.mocked(Notifications.setBadgeCountAsync).mockReset(); + vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(true); setPlatformOS('ios'); vi.mocked(Sharing.isAvailableAsync).mockReset(); vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(true); @@ -587,14 +593,11 @@ describe('handleMobileHostBridgeMessage', () => { }); test('原生异常对象不会透传非协议错误码', async () => { - vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber) - .mockImplementationOnce(() => { - throw { - code: 'native_badge_failure', - message: 'native badge failed', - nativeStackIOS: ['private native frame'], - }; - }); + vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValueOnce({ + code: 'native_badge_failure', + message: 'native badge failed', + nativeStackIOS: ['private native frame'], + }); const response = await send( request('app.setBadgeCount', { @@ -606,7 +609,7 @@ describe('handleMobileHostBridgeMessage', () => { expect(failedResponse.error).toEqual({ code: 'host_error', - message: 'mobile host bridge request failed', + message: 'app badge update unavailable', }); }); @@ -878,8 +881,72 @@ describe('handleMobileHostBridgeMessage', () => { expectOk(response); expect( - PushNotificationIOS.setApplicationIconBadgeNumber, + Notifications.setBadgeCountAsync, ).toHaveBeenCalledWith(12); + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + }); + + test('app.setBadgeCount 在 iOS 角标权限缺失时请求 badge 权限', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + GRANTED_NOTIFICATION_PERMISSION, + ); + + const response = await send( + request('app.setBadgeCount', { + count: 2, + }), + ); + + expectOk(response); + expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({ + ios: { + allowAlert: false, + allowBadge: true, + allowSound: false, + }, + }); + expect( + Notifications.setBadgeCountAsync, + ).toHaveBeenCalledWith(2); + }); + + test('app.setBadgeCount 在 iOS 角标权限拒绝时不返回成功', async () => { + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue( + DENIED_NOTIFICATION_PERMISSION, + ); + + const response = await send( + request('app.setBadgeCount', { + count: 1, + }), + ); + + expect(expectFailed(response).error).toEqual({ + code: 'host_error', + message: 'app badge permission denied', + }); + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + }); + + test('app.setBadgeCount 在 iOS 系统拒绝设置时不返回成功', async () => { + vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false); + + const response = await send( + request('app.setBadgeCount', { + count: 1, + }), + ); + + expect(expectFailed(response).error).toEqual({ + code: 'host_error', + message: 'app badge update unavailable', + }); }); test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => { @@ -892,7 +959,7 @@ describe('handleMobileHostBridgeMessage', () => { 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(); + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); setPlatformOS('android'); const unsupported = await send( @@ -902,7 +969,7 @@ describe('handleMobileHostBridgeMessage', () => { ); expect(expectFailed(unsupported).error.code).toBe('unsupported_capability'); - expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled(); + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); }); test('share.open 使用直接分享 payload 调起系统分享', async () => { diff --git a/apps/mobile-shell/src/host-bridge/dispatch.test.ts b/apps/mobile-shell/src/host-bridge/dispatch.test.ts index 67600f370..0bcb69ead 100644 --- a/apps/mobile-shell/src/host-bridge/dispatch.test.ts +++ b/apps/mobile-shell/src/host-bridge/dispatch.test.ts @@ -90,9 +90,6 @@ vi.mock('react-native', () => ({ Platform: { OS: 'android', }, - PushNotificationIOS: { - setApplicationIconBadgeNumber: vi.fn(), - }, })); function request(method: HostBridgeMethod): HostBridgeRequest { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b28c01f79..c1e2c8a41 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -101,9 +101,9 @@ - 2026-06-19 移动壳触觉反馈模块边界:Expo `haptics.impact` 的 HostBridge payload 解析、共享 style 归一、`light` / `medium` / `heavy` 到 `Haptics.ImpactFeedbackStyle` 的映射、真实 `Haptics.impactAsync(...)` 调用和成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/haptics.ts`;`dispatch.ts` 只负责把完整 request 委托给 `runMobileHostBridgeHapticsImpact(...)`,不得直接导入 `expo-haptics`、读取 `HapticsImpactPayload`、调用 `Haptics.impactAsync` 或包装触觉反馈成功响应。移动壳配置检查会覆盖该模块结构、共享 style 边界和 dispatch 委托关系,避免触觉反馈能力散落到分发层。 - 2026-06-20 移动触觉反馈单测边界:`apps/mobile-shell/src/host-bridge/haptics.test.ts` 直接覆盖 `haptics.impact` 的 `light` / `medium` / `heavy` 到 Expo Haptics style 映射、缺省 `light`、未知 style 不触发设备反馈、HostBridge 成功响应和 `invalid_request` 失败包装;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免触觉反馈边界只靠完整 HostBridge bridge 流程间接覆盖。 - 2026-06-18 分享卡图片导出:新增 `file.exportImage` HostBridge capability,H5 分享卡下载在 native app 中优先把 canvas 生成的 base64 图片交给宿主导出;Expo 壳写缓存图片后交给系统分享 / 保存面板,Tauri 壳通过系统保存对话框写入图片字节。该能力只接受 `image/png` / `image/jpeg` / `image/webp`、单次 5 MiB 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。Expo 图片导出的 payload 校验、缓存写入、系统分享 / 保存面板和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。 -- 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` HostBridge capability,H5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数并在宿主未声明时静默 fallback;Expo 壳只在 iOS 声明,并通过 Expo Notifications 查询 / 请求 `allowBadge` 权限后调用 `setBadgeCountAsync(count)`,只有系统返回 `true` 才报告成功,Android 不声明、不返回成功;Tauri 壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回真实错误。 - 2026-06-18 草稿生成未读角标:平台壳层把“可见作品架里未读的草稿生成完成更新”同步到 `app.setBadgeCount`;同一草稿的 work/profile/session 等多个恢复 ID 只计 1,已读、失败、生成中和不可见草稿不计入。该角标只消费已有 HostBridge 能力,宿主不支持或设置失败不影响 H5 红点、作品架或后端状态。 -- 2026-06-19 原生壳角标边界:Expo `app.setBadgeCount` 的 iOS 平台判定、共享上限校验、`PushNotificationIOS.setApplicationIconBadgeNumber(...)` 调用和 HostBridge 成功 / 失败响应映射统一收口在 `apps/mobile-shell/src/host-bridge/badge.ts`;Tauri `app.setBadgeCount` 的 payload 校验、清除语义、主窗口 `set_badge_count` 调用和 HostBridge 响应映射统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`。两端 `dispatch` 只负责委托对应 badge 模块,配置检查会拒绝分发层直接导入角标底层 API、重声明数量边界或包装角标成功响应。 +- 2026-06-19 原生壳角标边界:Expo `app.setBadgeCount` 的 iOS 平台判定、共享上限校验、badge 权限确认、`Notifications.setBadgeCountAsync(count)` 返回值校验和 HostBridge 成功 / 失败响应映射统一收口在 `apps/mobile-shell/src/host-bridge/badge.ts`;Tauri `app.setBadgeCount` 的 payload 校验、清除语义、主窗口 `set_badge_count` 调用和 HostBridge 响应映射统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`。两端 `dispatch` 只负责委托对应 badge 模块,配置检查会拒绝分发层直接导入角标底层 API、重声明数量边界或包装角标成功响应。 - 2026-06-18 宿主外观只读查询:新增 `appearance.getColorScheme` HostBridge capability,Expo 壳通过 React Native `Appearance.getColorScheme()` 读取系统配色,Tauri 壳通过主窗口 `theme()` 读取窗口主题;该能力只返回 `light` / `dark` / `unknown`,不设置 H5 主题、不覆盖系统主题,也不作为强制 UI 样式入口。 - 2026-06-19 原生壳外观查询边界:Expo `appearance.getColorScheme` 的系统配色读取、HostBridge 配色归一和成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/appearance.ts`;Tauri `appearance.getColorScheme` 的主窗口 `theme()` 读取、`light / dark / unknown` 映射和 HostBridge 响应包装统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`。两端 `dispatch` 只负责委托对应 appearance 模块,配置检查会拒绝分发层直接读取系统配色、窗口主题或包装外观查询成功响应。 - 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 平台细节。 @@ -3047,7 +3047,7 @@ ## 2026-06-20 移动角标 HostBridge 单测边界 - 背景:Expo 移动壳 `app.setBadgeCount` 只在 iOS capability profile 中声明,Android 请求到达时必须明确返回 unsupported;此前 iOS 设置 / 清除、非法 payload 和 Android unsupported 主要压在巨型 bridge 测试中,缺少对 `badge.ts` helper 的直接覆盖。 -- 决策:新增 `apps/mobile-shell/src/host-bridge/badge.test.ts`,直接覆盖 iOS `PushNotificationIOS.setApplicationIconBadgeNumber` 设置与清除、非法数量和缺少 payload 时不触碰系统角标,以及 Android 在 payload 校验前返回 `unsupported_capability` 的顺序;移动壳单端配置检查和根级原生壳门禁登记该测试文件并反查关键断言片段。该变更不把 `app.setBadgeCount` 加入 Android base capability。 +- 决策:新增 `apps/mobile-shell/src/host-bridge/badge.test.ts`,直接覆盖 iOS badge 权限已存在、权限缺失时请求 `allowBadge`、权限拒绝不触碰系统角标、`setBadgeCountAsync(false)` / reject 映射为稳定失败、非法数量和缺少 payload 时不触碰系统角标,以及 Android 在 payload 校验前返回 `unsupported_capability` 的顺序;移动壳单端配置检查和根级原生壳门禁登记该测试文件并反查关键断言片段。该变更不把 `app.setBadgeCount` 加入 Android base capability。 - 影响范围:`apps/mobile-shell/src/host-bridge/badge.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 - 验证方式:`npm run mobile-shell:test -- src/host-bridge/badge.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 31e3c65f5..8217a6837 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -144,7 +144,7 @@ type HostBridgeEvent = { | `navigation.canGoBack` | 通知 H5 宿主返回栈状态 | 支持事件 | 支持 H5 当前文档路由栈事件 | | `app.openExternalUrl` | 用系统浏览器打开外链 | 支持白名单协议 | 支持白名单协议 | | `app.setTitle` | 同步宿主窗口标题 | 不声明 | 支持 | -| `app.setBadgeCount` | 设置应用 / 任务栏角标 | 仅 iOS 支持,Android 不声明 | 支持;平台底层不支持时返回错误 | +| `app.setBadgeCount` | 设置应用 / 任务栏角标 | 仅 iOS 支持,需 Expo Notifications badge 权限和 `setBadgeCountAsync` 成功返回 | 支持;平台底层不支持时返回错误 | | `network.status` | 查询宿主网络状态 | 支持 Expo Network | 支持主站可达性短超时查询 | | `network.statusChanged` | 通知 H5 网络状态变化 | 支持 Expo Network 事件 | 桌面暂不声明,避免把 WebView online / offline 当作 Rust 桌面网络事实 | | `clipboard.writeText` | 写剪贴板 | 支持 | 支持 | @@ -302,7 +302,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`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.importText`、`file.importDocument`、`file.exportImage`、`file.importImage`、`file.captureImage`、`scanner.scanQrCode`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `