接入原生壳本地通知能力

新增 notification.showLocal HostBridge 契约和 H5 facade

移动端通过 expo-notifications 发送即时本地通知

桌面端通过 Tauri notification 插件发送系统通知

更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
2026-06-18 04:24:55 +08:00
parent f34f98c1a0
commit bbfe4b7181
19 changed files with 685 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ import {
normalizeHostBridgeExportFileName,
normalizeHostBridgeExternalUrl,
normalizeHostBridgeLifecycleState,
normalizeHostBridgeLocalNotification,
} from './hostBridge';
describe('HostBridge shared contract helpers', () => {
@@ -57,11 +58,52 @@ describe('HostBridge shared contract helpers', () => {
expect(isHostBridgeCapability('file.importImage')).toBe(true);
expect(isHostBridgeCapability('file.imageDropped')).toBe(true);
expect(isHostBridgeCapability('app.setBadgeCount')).toBe(true);
expect(isHostBridgeCapability('notification.showLocal')).toBe(true);
expect(isHostBridgeCapability('navigation.canGoBack')).toBe(true);
expect(isHostBridgeCapability('unknown.capability')).toBe(false);
expect(isHostBridgeCapability(null)).toBe(false);
});
test('归一化宿主本地通知内容', () => {
expect(
normalizeHostBridgeLocalNotification({
title: ' 生成完成 ',
body: ' 作品已准备好 可以试玩 ',
}),
).toEqual({
title: '生成完成',
body: '作品已准备好 可以试玩',
});
expect(
normalizeHostBridgeLocalNotification({
title: '生成完成',
body: '',
}),
).toEqual({
title: '生成完成',
});
expect(
normalizeHostBridgeLocalNotification({
title: 'a'.repeat(90),
body: 'b'.repeat(250),
}),
).toEqual({
title: 'a'.repeat(80),
body: 'b'.repeat(240),
});
expect(normalizeHostBridgeLocalNotification({ title: '' })).toBeNull();
expect(
normalizeHostBridgeLocalNotification({ title: '生成\n完成' }),
).toBeNull();
expect(
normalizeHostBridgeLocalNotification({
title: '生成完成',
body: '坏\u0001内容',
}),
).toBeNull();
expect(normalizeHostBridgeLocalNotification(null)).toBeNull();
});
test('归一化宿主角标数量', () => {
expect(normalizeHostBridgeBadgeCount(0)).toBe(0);
expect(normalizeHostBridgeBadgeCount(12)).toBe(12);

View File

@@ -30,6 +30,7 @@ export const HOST_BRIDGE_METHODS = [
'file.exportImage',
'file.importImage',
'haptics.impact',
'notification.showLocal',
] as const;
export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number];
@@ -304,6 +305,64 @@ export type HapticsImpactPayload = {
style?: 'light' | 'medium' | 'heavy';
};
export type LocalNotificationPayload = {
title: string;
body?: string;
};
export const HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH = 80;
export const HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH = 240;
function normalizeHostBridgePlainText(
value: unknown,
maxLength: number,
required: boolean,
) {
if (typeof value !== 'string') {
return required ? null : undefined;
}
if (hasHostBridgeControlCharacter(value)) {
return null;
}
const text = value.trim().replace(/\s+/g, ' ');
if (!text) {
return required ? null : undefined;
}
return text.slice(0, maxLength);
}
export function normalizeHostBridgeLocalNotification(
payload: unknown,
): LocalNotificationPayload | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const candidate = payload as Partial<LocalNotificationPayload>;
const title = normalizeHostBridgePlainText(
candidate.title,
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH,
true,
);
if (!title) {
return null;
}
const body = normalizeHostBridgePlainText(
candidate.body,
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH,
false,
);
if (body === null) {
return null;
}
return body ? { title, body } : { title };
}
export type ShareSetTargetPayload = {
target: unknown;
};