收紧移动壳角标真实结果

移动 iOS 角标改为请求 badge 权限后调用 Expo Notifications

角标设置以 setBadgeCountAsync 返回值作为成功依据

测试和门禁覆盖权限拒绝与系统拒绝设置失败

文档和项目记忆同步角标能力边界
This commit is contained in:
2026-06-20 18:00:51 +08:00
parent 33e56fb56f
commit ae3cdf4e69
8 changed files with 335 additions and 78 deletions
+15 -4
View File
@@ -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()',
+166 -45
View File
@@ -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<ReturnType<typeof Notifications.getPermissionsAsync>>;
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();
});
});
+64 -3
View File
@@ -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<typeof Notifications.getPermissionsAsync>
>;
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);
}
@@ -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 () => {
@@ -90,9 +90,6 @@ vi.mock('react-native', () => ({
Platform: {
OS: 'android',
},
PushNotificationIOS: {
setApplicationIconBadgeNumber: vi.fn(),
},
}));
function request(method: HostBridgeMethod): HostBridgeRequest {
@@ -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 capabilityH5 分享卡下载在 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 capabilityH5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数并在宿主未声明时静默 fallback;Expo 壳只在 iOS 声明并通过 React Native `PushNotificationIOS` 设置应用图标角标Android 不声明、不伪造成功;Tauri 壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回真实错误。
- 2026-06-18 应用角标能力:新增 `app.setBadgeCount` HostBridge capabilityH5 只传 `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 capabilityExpo 壳通过 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 capabilityExpo 壳通过 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`
File diff suppressed because one or more lines are too long
@@ -76,7 +76,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
移动壳的触觉反馈 payload 解析、style 归一、Expo style 映射和真实设备反馈调用都必须留在 `apps/mobile-shell/src/host-bridge/haptics.ts``dispatch.ts` 只把 `request.payload` 委托给该模块。
- `showHostLocalNotification()`:原生 App 宿主的受控即时本地通知入口。H5 只能传必填 `title` 和可选 `body`,两者都会去除首尾空白、折叠普通空白、限制长度并拒绝控制字符;Expo 移动壳通过 `expo-notifications` 请求通知权限、创建 Android 本地通知 channel 并立刻调度本地通知,Android channel id 固定为共享契约 `HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID`Tauri 桌面壳通过 Rust 侧 `tauri-plugin-notification` 先检查系统通知权限,处于 prompt 状态时只在 Rust 侧请求一次权限,最终授权后才发送系统通知。成功结果统一为共享契约 `HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT`,只表示通知已交给系统通知层,不承诺用户一定看见或点击。该能力不包含远程推送、token 注册、定时提醒、后台远程通知或任意通知插件透传,宿主未声明、权限拒绝或系统失败时由 H5 视作失败并继续主流程。当前 H5 只在现有草稿生成任务收口为完成或失败时请求即时本地通知;通知按草稿来源去重,同一草稿重新进入生成中后才允许再次通知,不改变队列状态、弹窗、作品架或后端裁决。平台壳同步层必须通过真实 `host_bridge_request` transport 测到 `notification.showLocal` 请求,不能只测模型文案或替换 facade。
- `setHostAppTitle()`:原生 App 宿主的受控窗口标题入口。H5 主站会按当前平台阶段先同步 `document.title`,再通过 `app.setTitle` 请求宿主窗口标题同步;H5 facade 和 Tauri 桌面壳都必须按共享契约 `HOST_BRIDGE_APP_TITLE_MAX_LENGTH` 清洗标题,拒绝空值和控制字符,最多保留 80 个字符。Tauri 桌面壳支持该能力,Expo 移动壳不声明时静默忽略。
- `setHostAppBadgeCount()`:原生 App 宿主的受控应用角标入口。H5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数,`0` 表示清除角标;Expo 移动壳只在 iOS 声明 `app.setBadgeCount` 并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明该能力,且请求实际到达 Android 壳时必须返回 `unsupported_capability`,不得伪造成功;Tauri 桌面壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回明确错误,由 H5 视作失败并继续主流程。当前 H5 只把“可见作品架里未读的草稿生成完成更新”同步为角标数,同一个草稿有多个恢复 ID 时只计 1,已读、失败、生成中和不可见草稿不计入;宿主不支持或设置失败不改变 H5 红点、作品架或后端状态。平台壳同步层必须通过真实 `host_bridge_request` transport 测到 `app.setBadgeCount` 请求,保证未读计数模型和原生壳消费链路同时被门禁覆盖。
- `setHostAppBadgeCount()`:原生 App 宿主的受控应用角标入口。H5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数,`0` 表示清除角标;Expo 移动壳只在 iOS 声明 `app.setBadgeCount`并通过 Expo Notifications 查询 / 请求 `allowBadge` 权限,再以 `Notifications.setBadgeCountAsync(count)` 的返回值作为成功依据;权限拒绝、权限状态不可读、系统返回 `false` 或原生 API 异常都必须返回明确失败,Android 不声明该能力,且请求实际到达 Android 壳时必须返回 `unsupported_capability`,不得返回成功;Tauri 桌面壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回明确错误,由 H5 视作失败并继续主流程。当前 H5 只把“可见作品架里未读的草稿生成完成更新”同步为角标数,同一个草稿有多个恢复 ID 时只计 1,已读、失败、生成中和不可见草稿不计入;宿主不支持或设置失败不改变 H5 红点、作品架或后端状态。平台壳同步层必须通过真实 `host_bridge_request` transport 测到 `app.setBadgeCount` 请求,保证未读计数模型和原生壳消费链路同时被门禁覆盖。
- `reloadHostWebView()`:原生 App 宿主的受控 WebView 刷新入口。H5 只能请求刷新当前承载主站的宿主 WebView;Expo 移动壳调用当前 `react-native-webview``reload()`Tauri 桌面壳调用主 `WebviewWindow.reload()`。该能力不接受 payload,不开放任意 URL 导航、脚本执行、Tauri guest API 或 RN WebView ref;成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。`AuthGate` 在登录态从未登录变为已登录、或从已登录变为未登录时优先调用该能力刷新当前容器;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`
- `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:``https:``mailto:``tel:`,相对路径会先归一化到当前站点绝对 URL,再通过共享契约 `normalizeHostBridgeExternalUrlPayload()` 清洗为 `{ url }` 载荷。Expo 移动壳消费该共享 payload normalizerTauri 桌面壳在 Rust 侧用 URL parser 镜像同一协议清单。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `<a>` 语义。H5 支付链接和微信 OAuth 登录授权 URL 也走该入口:原生壳未声明真实 `payment.request` / `auth.requestLogin` 前,微信 H5 支付 URL 和后端返回的微信登录授权 URL 优先交给宿主系统浏览器,宿主未处理时才回退当前 WebView 跳转;不得把 H5 支付或网页登录伪装成已完成的原生支付 / 原生登录。
- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录和内置独立 H5 体验入口等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URLTauri 桌面壳同样只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内跳转。H5 facade 在 `native_app` 下发送 `navigation.openNativePage` 前先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标,避免把明显不安全的跳转请求交给原生壳;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续由宿主二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。平台首页的儿童动作热身 Demo 入口在 `native_app` 且宿主声明 `navigation.openNativePage` 时必须优先走该 facade 跳转 `/child-motion-demo`,普通浏览器、小程序和未声明能力的裁剪壳才回退浏览器跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。