接入原生壳应用角标能力
新增 HostBridge app.setBadgeCount 契约和 H5 能力门控 Expo 壳按平台声明能力并在 iOS 调用系统角标 API Tauri 壳通过主窗口设置任务栏角标并校验 payload 补齐角标能力测试、漂移检查和架构文档
This commit is contained in:
@@ -7,7 +7,7 @@ import { WebView } from 'react-native-webview';
|
||||
import {
|
||||
configureMobileHostBridgeNavigation,
|
||||
handleMobileHostBridgeMessage,
|
||||
MOBILE_HOST_CAPABILITIES,
|
||||
resolveMobileHostCapabilities,
|
||||
} from './src/mobileHostBridge';
|
||||
import { buildMobileShellUrlFromDeepLink } from './src/mobileShellDeepLink';
|
||||
import {
|
||||
@@ -33,7 +33,7 @@ export default function App() {
|
||||
() => ({
|
||||
platform: Platform.OS === 'ios' ? 'ios' as const : 'android' as const,
|
||||
hostVersion,
|
||||
capabilities: MOBILE_HOST_CAPABILITIES,
|
||||
capabilities: resolveMobileHostCapabilities(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -47,7 +47,12 @@ const mobileCapabilities = extractStringArrayExport(
|
||||
bridgeSource,
|
||||
'MOBILE_HOST_CAPABILITIES',
|
||||
);
|
||||
const iosMobileCapabilities = extractStringArrayExport(
|
||||
bridgeSource,
|
||||
'IOS_MOBILE_HOST_CAPABILITIES',
|
||||
);
|
||||
const mobileCapabilitySet = new Set(mobileCapabilities);
|
||||
const iosMobileCapabilitySet = new Set(iosMobileCapabilities);
|
||||
const unknownMobileCapabilities = mobileCapabilities.filter(
|
||||
(capability) => !sharedCapabilities.includes(capability),
|
||||
);
|
||||
@@ -57,7 +62,16 @@ if (unknownMobileCapabilities.length > 0) {
|
||||
);
|
||||
}
|
||||
|
||||
for (const capability of mobileCapabilities) {
|
||||
const unknownIosMobileCapabilities = iosMobileCapabilities.filter(
|
||||
(capability) => !sharedCapabilities.includes(capability),
|
||||
);
|
||||
if (unknownIosMobileCapabilities.length > 0) {
|
||||
throw new Error(
|
||||
`iOS mobile shell declares unknown HostBridge capabilities: ${unknownIosMobileCapabilities.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const capability of iosMobileCapabilities) {
|
||||
const switchCase = `case '${capability}':`;
|
||||
if (
|
||||
capability !== 'host.events' &&
|
||||
@@ -129,8 +143,12 @@ for (const snippet of [
|
||||
}
|
||||
|
||||
const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES";
|
||||
if (!appSource.includes(capabilityQuerySnippet)) {
|
||||
throw new Error('mobile shell URL must use MOBILE_HOST_CAPABILITIES');
|
||||
if (appSource.includes(capabilityQuerySnippet)) {
|
||||
throw new Error('mobile shell URL must resolve platform-aware capabilities');
|
||||
}
|
||||
|
||||
if (!appSource.includes('capabilities: resolveMobileHostCapabilities()')) {
|
||||
throw new Error('mobile shell URL must use resolveMobileHostCapabilities()');
|
||||
}
|
||||
|
||||
for (const capability of [
|
||||
@@ -149,3 +167,11 @@ for (const capability of [
|
||||
throw new Error(`mobile shell capabilities missing ${capability}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!iosMobileCapabilitySet.has('app.setBadgeCount')) {
|
||||
throw new Error('iOS mobile shell capabilities missing app.setBadgeCount');
|
||||
}
|
||||
|
||||
if (mobileCapabilitySet.has('app.setBadgeCount')) {
|
||||
throw new Error('Android mobile shell base capabilities must not include app.setBadgeCount');
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { File, Paths } from 'expo-file-system';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { Platform, Share } from 'react-native';
|
||||
import { Platform, PushNotificationIOS, Share } from 'react-native';
|
||||
|
||||
import {
|
||||
type ClipboardWriteTextPayload,
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
type HostBridgeRequest,
|
||||
type HostBridgeResponse,
|
||||
type NavigateNativePagePayload,
|
||||
normalizeHostBridgeBadgeCount,
|
||||
normalizeHostBridgeExportFileName,
|
||||
normalizeHostBridgeExternalUrl,
|
||||
type OpenExternalUrlPayload,
|
||||
type SetBadgeCountPayload,
|
||||
type ShareOpenPayload,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import { resolveMobileShellWebViewUrl } from './mobileShellNavigation';
|
||||
@@ -50,6 +52,17 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
'haptics.impact',
|
||||
];
|
||||
|
||||
export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
...MOBILE_HOST_CAPABILITIES,
|
||||
'app.setBadgeCount',
|
||||
];
|
||||
|
||||
export function resolveMobileHostCapabilities(platform = Platform.OS) {
|
||||
return platform === 'ios'
|
||||
? IOS_MOBILE_HOST_CAPABILITIES
|
||||
: MOBILE_HOST_CAPABILITIES;
|
||||
}
|
||||
|
||||
export type MobileHostBridgeNavigation = {
|
||||
allowedOrigin: string;
|
||||
openWebViewUrl: (url: string) => void;
|
||||
@@ -277,6 +290,25 @@ async function runHaptics(payload: unknown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function setBadgeCount(payload: unknown) {
|
||||
if (Platform.OS !== 'ios') {
|
||||
throw {
|
||||
code: 'unsupported_capability',
|
||||
message: 'app badge count is only supported on iOS mobile shell',
|
||||
} satisfies HostBridgeError;
|
||||
}
|
||||
|
||||
const count = normalizeHostBridgeBadgeCount(
|
||||
(payload as SetBadgeCountPayload | undefined)?.count,
|
||||
);
|
||||
if (count === null) {
|
||||
throw invalidRequest('count must be an integer between 0 and 99999');
|
||||
}
|
||||
|
||||
PushNotificationIOS.setApplicationIconBadgeNumber(count);
|
||||
return true;
|
||||
}
|
||||
|
||||
function stringField(value: unknown, field: string) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
@@ -385,7 +417,7 @@ async function handleRequest(request: HostBridgeRequest) {
|
||||
platform: Platform.OS === 'ios' ? 'ios' : 'android',
|
||||
hostVersion: '0.1.0',
|
||||
bridgeVersion: HOST_BRIDGE_VERSION,
|
||||
capabilities: MOBILE_HOST_CAPABILITIES,
|
||||
capabilities: resolveMobileHostCapabilities(),
|
||||
});
|
||||
case 'app.openExternalUrl':
|
||||
return ok(request, await openExternalUrl(request.payload));
|
||||
@@ -397,6 +429,8 @@ async function handleRequest(request: HostBridgeRequest) {
|
||||
return ok(request, await exportImageFile(request.payload));
|
||||
case 'haptics.impact':
|
||||
return ok(request, await runHaptics(request.payload));
|
||||
case 'app.setBadgeCount':
|
||||
return ok(request, setBadgeCount(request.payload));
|
||||
case 'share.open':
|
||||
return ok(request, await openShare(request.payload));
|
||||
case 'share.setTarget':
|
||||
|
||||
@@ -23,4 +23,28 @@ describe('buildMobileShellUrl', () => {
|
||||
);
|
||||
expect(url.searchParams.get('work')).toBe('PZ-1');
|
||||
});
|
||||
|
||||
test('支持按平台注入不同能力清单', () => {
|
||||
const iosUrl = new URL(
|
||||
buildMobileShellUrl('https://app.test/', {
|
||||
platform: 'ios',
|
||||
hostVersion: '0.1.0',
|
||||
capabilities: ['host.getRuntime', 'app.setBadgeCount'],
|
||||
}),
|
||||
);
|
||||
const androidUrl = new URL(
|
||||
buildMobileShellUrl('https://app.test/', {
|
||||
platform: 'android',
|
||||
hostVersion: '0.1.0',
|
||||
capabilities: ['host.getRuntime'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(iosUrl.searchParams.get('hostCapabilities')).toBe(
|
||||
'host.getRuntime,app.setBadgeCount',
|
||||
);
|
||||
expect(androidUrl.searchParams.get('hostCapabilities')).toBe(
|
||||
'host.getRuntime',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user