接入原生壳应用角标能力

新增 HostBridge app.setBadgeCount 契约和 H5 能力门控

Expo 壳按平台声明能力并在 iOS 调用系统角标 API

Tauri 壳通过主窗口设置任务栏角标并校验 payload

补齐角标能力测试、漂移检查和架构文档
This commit is contained in:
2026-06-18 01:50:15 +08:00
parent 910625d5e1
commit 6b39bdbe19
15 changed files with 336 additions and 19 deletions

View File

@@ -1,7 +1,7 @@
import * as Haptics from 'expo-haptics';
import * as Linking from 'expo-linking';
import * as Sharing from 'expo-sharing';
import { Share } from 'react-native';
import { Platform, PushNotificationIOS, Share } from 'react-native';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
@@ -73,6 +73,9 @@ vi.mock('react-native', () => ({
Platform: {
OS: 'ios',
},
PushNotificationIOS: {
setApplicationIconBadgeNumber: vi.fn(),
},
Share: {
share: vi.fn(),
},
@@ -122,9 +125,15 @@ function expectFailed(response: HostBridgeResponse) {
return response;
}
function setPlatformOS(os: 'ios' | 'android') {
(Platform as { OS: 'ios' | 'android' }).OS = os;
}
afterEach(() => {
vi.mocked(Haptics.impactAsync).mockReset();
vi.mocked(Linking.openURL).mockReset();
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset();
setPlatformOS('ios');
vi.mocked(Sharing.isAvailableAsync).mockReset();
vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(true);
vi.mocked(Sharing.shareAsync).mockReset();
@@ -152,10 +161,29 @@ describe('handleMobileHostBridgeMessage', () => {
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toEqual(
expect.arrayContaining(['host.events', 'navigation.canGoBack']),
expect.arrayContaining([
'host.events',
'navigation.canGoBack',
'app.setBadgeCount',
]),
);
});
test('Android runtime 不声明 iOS 角标能力', async () => {
setPlatformOS('android');
const response = await send(request('host.getRuntime'));
const okResponse = expectOk(response);
expect(okResponse.result).toMatchObject({
shell: 'expo_mobile',
platform: 'android',
});
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).not.toContain('app.setBadgeCount');
});
test('navigation.openNativePage 把同源路径切到移动壳 WebView', async () => {
const openWebViewUrl = vi.fn();
configureMobileHostBridgeNavigation({
@@ -241,6 +269,40 @@ describe('handleMobileHostBridgeMessage', () => {
);
});
test('app.setBadgeCount 在 iOS 调起系统角标能力', async () => {
const response = await send(
request('app.setBadgeCount', {
count: 12,
}),
);
expectOk(response);
expect(
PushNotificationIOS.setApplicationIconBadgeNumber,
).toHaveBeenCalledWith(12);
});
test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => {
const invalid = await send(
request('app.setBadgeCount', {
count: 1.5,
}),
);
expect(expectFailed(invalid).error.code).toBe('invalid_request');
expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled();
setPlatformOS('android');
const unsupported = await send(
request('app.setBadgeCount', {
count: 1,
}),
);
expect(expectFailed(unsupported).error.code).toBe('unsupported_capability');
expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled();
});
test('share.open 使用直接分享 payload 调起系统分享', async () => {
const response = await send(
request('share.open', {