diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index cd894987e..9e780b89d 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -2036,6 +2036,11 @@ for (const notificationSnippet of [ "invalidRequest('title is required')", "message: 'notification permission unavailable'", "message: 'notification delivery unavailable'", + 'logMobileNotificationFailure', + 'mobile notification failed for', + "logMobileNotificationFailure('permission.current', error)", + "logMobileNotificationFailure('permission.request', error)", + "logMobileNotificationFailure('delivery.schedule', error)", 'showMobileLocalNotification(notification)', 'ok(request, await showMobileLocalNotification(notification))', 'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT', @@ -2052,6 +2057,9 @@ for (const snippet of [ 'maps native notification schedule failures to stable delivery errors', "message: 'notification permission unavailable'", "message: 'notification delivery unavailable'", + 'mobile notification failed for permission.current', + 'mobile notification failed for permission.request', + 'mobile notification failed for delivery.schedule', 'expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled()', 'expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled()', ]) { @@ -2774,6 +2782,13 @@ for (const snippet of [ 'app badge permission denied', 'app badge update unavailable', 'app badge count is only supported on iOS mobile shell', + 'logMobileBadgeFailure', + 'mobile app badge failed for', + "logMobileBadgeFailure('permission.current', error)", + "logMobileBadgeFailure('permission.request', error)", + "logMobileBadgeFailure('update.set_count', error)", + "logMobileBadgeFailure(", + "'update.rejected'", ]) { if (!badgeSource.includes(snippet)) { throw new Error(`mobile shell badge module is missing ${snippet}`); @@ -2799,6 +2814,12 @@ for (const snippet of [ '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', + 'maps current badge permission lookup failures to a stable HostBridge error', + 'maps badge permission request failures to a stable HostBridge error', + 'mobile app badge failed for permission.current', + 'mobile app badge failed for permission.request', + 'mobile app badge failed for update.set_count', + 'mobile app badge failed for update.rejected', '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', diff --git a/apps/mobile-shell/src/host-bridge/badge.test.ts b/apps/mobile-shell/src/host-bridge/badge.test.ts index 549ac88b6..87b46b584 100644 --- a/apps/mobile-shell/src/host-bridge/badge.test.ts +++ b/apps/mobile-shell/src/host-bridge/badge.test.ts @@ -157,7 +157,60 @@ describe('mobile app badge helper', () => { expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); }); + test('maps current badge permission lookup failures to a stable HostBridge error', async () => { + const error = new Error('native badge permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge permission unavailable', + }); + + expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for permission.current', + error, + ); + warnSpy.mockRestore(); + }); + + test('maps badge permission request failures to a stable HostBridge error', async () => { + const error = new Error('native badge permission request failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( + BADGE_DENIED_PERMISSION, + ); + vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(error); + + await expect( + setMobileAppBadgeCount( + request({ + count: 1, + }), + ), + ).rejects.toMatchObject({ + code: 'host_error', + message: 'app badge permission unavailable', + }); + + expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for permission.request', + error, + ); + warnSpy.mockRestore(); + }); + test('maps native badge update false results to a stable HostBridge error', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false); await expect( @@ -170,12 +223,17 @@ describe('mobile app badge helper', () => { code: 'host_error', message: 'app badge update unavailable', }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for update.rejected', + expect.any(Error), + ); + warnSpy.mockRestore(); }); test('maps native badge update rejections to a stable HostBridge error', async () => { - vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValue( - new Error('native badge failed'), - ); + const error = new Error('native badge failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValue(error); await expect( setMobileAppBadgeCount( @@ -187,6 +245,11 @@ describe('mobile app badge helper', () => { code: 'host_error', message: 'app badge update unavailable', }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for update.set_count', + error, + ); + warnSpy.mockRestore(); }); test('rejects invalid badge counts before touching the system badge', async () => { diff --git a/apps/mobile-shell/src/host-bridge/badge.ts b/apps/mobile-shell/src/host-bridge/badge.ts index 47132b967..6e7d985f3 100644 --- a/apps/mobile-shell/src/host-bridge/badge.ts +++ b/apps/mobile-shell/src/host-bridge/badge.ts @@ -18,11 +18,16 @@ function hasBadgePermission(permission: NotificationPermissionStatus) { return permission.ios?.allowsBadge === true; } +function logMobileBadgeFailure(label: string, error: unknown) { + console.warn(`mobile app badge failed for ${label}`, error); +} + async function ensureMobileBadgePermission() { let currentPermission: NotificationPermissionStatus; try { currentPermission = await Notifications.getPermissionsAsync(); - } catch { + } catch (error) { + logMobileBadgeFailure('permission.current', error); throw { code: 'host_error', message: 'app badge permission unavailable', @@ -41,7 +46,8 @@ async function ensureMobileBadgePermission() { allowSound: false, }, }); - } catch { + } catch (error) { + logMobileBadgeFailure('permission.request', error); throw { code: 'host_error', message: 'app badge permission unavailable', @@ -76,13 +82,18 @@ export async function setMobileAppBadgeCount(request: HostBridgeRequest) { let updated = false; try { updated = await Notifications.setBadgeCountAsync(count); - } catch { + } catch (error) { + logMobileBadgeFailure('update.set_count', error); throw { code: 'host_error', message: 'app badge update unavailable', } satisfies HostBridgeError; } if (!updated) { + logMobileBadgeFailure( + 'update.rejected', + new Error('setBadgeCountAsync returned false'), + ); throw { code: 'host_error', message: 'app badge update unavailable', diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 82767516f..5c2cacc5d 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -593,24 +593,36 @@ describe('handleMobileHostBridgeMessage', () => { }); test('原生异常对象不会透传非协议错误码', async () => { - vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValueOnce({ + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const nativeError = { code: 'native_badge_failure', message: 'native badge failed', nativeStackIOS: ['private native frame'], - }); + }; + try { + vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValueOnce({ + ...nativeError, + }); - const response = await send( - request('app.setBadgeCount', { - count: 1, - }), - ); + const response = await send( + request('app.setBadgeCount', { + count: 1, + }), + ); - const failedResponse = expectFailed(response); + const failedResponse = expectFailed(response); - expect(failedResponse.error).toEqual({ - code: 'host_error', - message: 'app badge update unavailable', - }); + expect(failedResponse.error).toEqual({ + code: 'host_error', + message: 'app badge update unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for update.set_count', + nativeError, + ); + } finally { + warnSpy.mockRestore(); + } }); test('app.openExternalUrl 原生异常返回稳定 host_error', async () => { @@ -935,18 +947,27 @@ describe('handleMobileHostBridgeMessage', () => { }); test('app.setBadgeCount 在 iOS 系统拒绝设置时不返回成功', async () => { - vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false); - const response = await send( - request('app.setBadgeCount', { - count: 1, - }), - ); + const response = await send( + request('app.setBadgeCount', { + count: 1, + }), + ); - expect(expectFailed(response).error).toEqual({ - code: 'host_error', - message: 'app badge update unavailable', - }); + expect(expectFailed(response).error).toEqual({ + code: 'host_error', + message: 'app badge update unavailable', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile app badge failed for update.rejected', + expect.any(Error), + ); + } finally { + warnSpy.mockRestore(); + } }); test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => { diff --git a/apps/mobile-shell/src/host-bridge/notifications.test.ts b/apps/mobile-shell/src/host-bridge/notifications.test.ts index f004778ce..8c1acc6bb 100644 --- a/apps/mobile-shell/src/host-bridge/notifications.test.ts +++ b/apps/mobile-shell/src/host-bridge/notifications.test.ts @@ -153,9 +153,9 @@ describe('mobile local notification helpers', () => { }); test('rejects delivery when current permission lookup is unavailable', async () => { - vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce( - new Error('native permission failed'), - ); + const error = new Error('native permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error); await expect( showMobileLocalNotification({ title: '生成完成' }), @@ -167,15 +167,20 @@ describe('mobile local notification helpers', () => { expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile notification failed for permission.current', + error, + ); + warnSpy.mockRestore(); }); test('rejects delivery when permission request is unavailable', async () => { + const error = new Error('native permission failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue( DENIED_NOTIFICATION_PERMISSION, ); - vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce( - new Error('native permission failed'), - ); + vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(error); await expect( showMobileLocalNotification({ title: '生成完成' }), @@ -186,6 +191,11 @@ describe('mobile local notification helpers', () => { expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile notification failed for permission.request', + error, + ); + warnSpy.mockRestore(); }); test('schedules iOS notification without channel trigger', async () => { @@ -227,10 +237,10 @@ describe('mobile local notification helpers', () => { }); test('maps Android channel setup failures to stable delivery errors', async () => { + const error = new Error('native channel failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); setPlatformOS('android'); - vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValueOnce( - new Error('native channel failed'), - ); + vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValueOnce(error); await expect( showMobileLocalNotification({ title: '生成完成' }), @@ -240,12 +250,17 @@ describe('mobile local notification helpers', () => { }); expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile notification failed for delivery.schedule', + error, + ); + warnSpy.mockRestore(); }); test('maps native notification schedule failures to stable delivery errors', async () => { - vi.mocked(Notifications.scheduleNotificationAsync).mockRejectedValueOnce( - new Error('native schedule failed'), - ); + const error = new Error('native schedule failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(Notifications.scheduleNotificationAsync).mockRejectedValueOnce(error); await expect( showMobileLocalNotification({ title: '生成完成' }), @@ -253,6 +268,11 @@ describe('mobile local notification helpers', () => { code: 'host_error', message: 'notification delivery unavailable', }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile notification failed for delivery.schedule', + error, + ); + warnSpy.mockRestore(); }); test('normalizes HostBridge payload and wraps structured delivery result', async () => { diff --git a/apps/mobile-shell/src/host-bridge/notifications.ts b/apps/mobile-shell/src/host-bridge/notifications.ts index 4a0b1913e..182a5e107 100644 --- a/apps/mobile-shell/src/host-bridge/notifications.ts +++ b/apps/mobile-shell/src/host-bridge/notifications.ts @@ -20,6 +20,10 @@ Notifications.setNotificationHandler({ }), }); +function logMobileNotificationFailure(label: string, error: unknown) { + console.warn(`mobile notification failed for ${label}`, error); +} + function hasNotificationPermission( permission: Awaited>, ) { @@ -35,7 +39,8 @@ async function ensureNotificationPermission() { >; try { currentPermission = await Notifications.getPermissionsAsync(); - } catch { + } catch (error) { + logMobileNotificationFailure('permission.current', error); throw { code: 'host_error', message: 'notification permission unavailable', @@ -56,7 +61,8 @@ async function ensureNotificationPermission() { allowSound: false, }, }); - } catch { + } catch (error) { + logMobileNotificationFailure('permission.request', error); throw { code: 'host_error', message: 'notification permission unavailable', @@ -92,7 +98,8 @@ export async function showMobileLocalNotification( ? { channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID } : null, }); - } catch { + } catch (error) { + logMobileNotificationFailure('delivery.schedule', error); throw { code: 'host_error', message: 'notification delivery unavailable', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8a1580211..d8336c25c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3106,6 +3106,12 @@ - 决策:`apps/mobile-shell/src/host-bridge/files.ts` 必须在 Expo Sharing 可用性 / 分享面板、DocumentPicker、文本 / base64 文件读取、相册 / 相机权限请求和相册 / 相机打开失败时记录 `mobile HostBridge file failed for ...` 日志;HostBridge 对 H5 仍只返回稳定 `host_error` / `unsupported_capability` / `cancelled` / `invalid_request` 语义,不透传原生异常明细。移动壳配置检查反查日志 helper、关键 label 和对应单测。 - 验证方式:`npm run mobile-shell:test -- src/host-bridge/files.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-20 移动壳通知与角标系统异常必须可观测 + +- 背景:Expo 移动壳已声明即时本地通知,iOS 额外声明应用角标;这些能力会触发系统权限读取、权限请求、Android channel 设置、通知调度和角标更新。如果原生 API 异常只被折叠成稳定 HostBridge 错误,H5 语义安全,但开发侧无法区分权限模块异常、系统通知调度失败或角标 API 拒绝。 +- 决策:`apps/mobile-shell/src/host-bridge/notifications.ts` 必须在权限读取 / 请求和通知投递失败时记录 `mobile notification failed for ...` 日志;`apps/mobile-shell/src/host-bridge/badge.ts` 必须在角标权限读取 / 请求、`setBadgeCountAsync` reject 和返回 `false` 时记录 `mobile app badge failed for ...` 日志。HostBridge 对 H5 仍只返回稳定错误语义;该约束不新增远程推送 token、后台通知、定时提醒或 Android 角标 capability。 +- 验证方式:`npm run mobile-shell:test -- src/host-bridge/notifications.test.ts src/host-bridge/badge.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-20 移动壳门禁脚本必须自登记自扫描 - 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 9acb9ddbd..0be5e0d64 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -336,6 +336,8 @@ GameBridge 禁止: 2026-06-18 追加:H5 的作品架未读草稿生成完成更新开始消费 `app.setBadgeCount`。Expo 移动壳仍只在 iOS 声明该能力,Android 不声明、不伪造成功;H5 只同步可见作品架内未读完成草稿数量,同一草稿多恢复 ID 只计 1,宿主不支持或设置失败不影响 H5 红点与作品架状态。根级原生壳门禁必须覆盖平台壳同步层通过真实 HostBridge transport 发出该 method,避免未读计数模型和宿主消费链路脱节。 +2026-06-20 追加:移动壳本地通知和 iOS 角标的原生异常必须可观测。Expo Notifications 权限读取 / 权限请求、本地通知 Android channel 设置 / 通知调度、iOS 角标权限读取 / 请求、`setBadgeCountAsync` reject 或返回 `false` 时,移动壳必须分别记录 `mobile notification failed for ...` 或 `mobile app badge failed for ...` 日志;HostBridge 回包仍只暴露稳定 `notification permission unavailable`、`notification delivery unavailable`、`app badge permission unavailable` 或 `app badge update unavailable` 语义。该约束不新增远程推送 token、后台通知、定时提醒或 Android 角标能力。 + 2026-06-18 追加:H5 的平台外部生成队列概览开始消费 `network.status` / `network.statusChanged`。宿主未声明网络能力时继续按原逻辑轮询;宿主明确离线或不可达时暂停概览请求,恢复在线后重新刷新,不改变生成任务、作品架或后端回读事实。 2026-06-18 追加:移动壳 `haptics.impact` 只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`;未知强度返回 `invalid_request`,不会静默降级成真实设备触觉反馈。桌面壳不声明该能力,H5 继续按 HostBridge fallback 处理。