收口移动本地通知边界

将 Expo 本地通知权限和调度收口到 notifications 模块

让 HostBridge dispatch 只负责通知 payload 归一和分发

同步原生壳结构门禁和架构文档清单
This commit is contained in:
2026-06-19 17:57:09 +08:00
parent cba6e29d1d
commit 5db17d1a12
7 changed files with 98 additions and 67 deletions
+21 -5
View File
@@ -14,6 +14,8 @@ const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
const notificationsPath = new URL('../src/host-bridge/notifications.ts', import.meta.url);
const notificationsSource = fs.readFileSync(notificationsPath, 'utf8');
const protocolPath = new URL('../src/host-bridge/protocol.ts', import.meta.url);
const protocolSource = fs.readFileSync(protocolPath, 'utf8');
const sharePath = new URL('../src/host-bridge/share.ts', import.meta.url);
@@ -73,6 +75,7 @@ const requiredMobileShellSourceModules = [
'host-bridge/capabilities.ts',
'host-bridge/dispatch.ts',
'host-bridge/files.ts',
'host-bridge/notifications.ts',
'host-bridge/protocol.ts',
'host-bridge/scanner.ts',
'host-bridge/share.ts',
@@ -1470,9 +1473,9 @@ for (const forbiddenHostBridgeRuntimeSnippet of ['atob(', 'Buffer.from']) {
}
if (
dispatchSource.includes("const LOCAL_NOTIFICATION_CHANNEL_ID = '") ||
dispatchSource.includes('"genarrative-local"') ||
dispatchSource.includes("'genarrative-local'")
hostBridgeSource.includes("const LOCAL_NOTIFICATION_CHANNEL_ID = '") ||
hostBridgeSource.includes('"genarrative-local"') ||
hostBridgeSource.includes("'genarrative-local'")
) {
throw new Error(
'mobile shell local notification channel id must come from shared contract',
@@ -1483,18 +1486,30 @@ if (sharedMobileLocalNotificationChannelId !== 'genarrative-local') {
throw new Error('shared mobile local notification channel id drifted');
}
if (!dispatchSource.includes('HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID')) {
if (!notificationsSource.includes('HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID')) {
throw new Error('mobile shell must use the shared local notification channel id');
}
if (
!/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test(
dispatchSource,
notificationsSource,
)
) {
throw new Error('mobile shell local notifications must stay immediate and channel-only');
}
if (
!dispatchSource.includes('showMobileLocalNotification(notification)') ||
dispatchSource.includes("from 'expo-notifications'") ||
dispatchSource.includes('Notifications.scheduleNotificationAsync') ||
dispatchSource.includes('Notifications.requestPermissionsAsync') ||
dispatchSource.includes('Notifications.setNotificationChannelAsync')
) {
throw new Error(
'mobile shell dispatch must delegate local notification delivery to notifications.ts',
);
}
for (const snippet of [
'file.exportText',
'file.importText',
@@ -1507,6 +1522,7 @@ for (const snippet of [
'file.exportAudio',
'clipboard.readText',
'notification.showLocal',
'showMobileLocalNotification',
'network.status',
'app.reloadWebView',
'getMobileNetworkStatus',
+2 -60
View File
@@ -1,7 +1,6 @@
import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
import * as Linking from 'expo-linking';
import * as Notifications from 'expo-notifications';
import {
Appearance,
Platform,
@@ -13,7 +12,6 @@ import {
type ClipboardWriteTextPayload,
type HapticsImpactPayload,
HOST_BRIDGE_BADGE_COUNT_MAX,
HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
HOST_BRIDGE_VERSION,
type HostBridgeError,
type HostBridgeRequest,
@@ -54,15 +52,7 @@ import {
import { resetQrScannerForTest, scanQrCode } from './scanner';
import { openShare } from './share';
import { buildMobileShellUrl } from '../shell/url';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
import { showMobileLocalNotification } from './notifications';
let currentShareTarget: unknown = null;
let navigation: MobileHostBridgeNavigation | null = null;
@@ -157,61 +147,13 @@ function setBadgeCount(payload: unknown) {
return true;
}
function hasNotificationPermission(
permission: Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>,
) {
return (
permission.granted ||
permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL
);
}
async function ensureNotificationPermission() {
const currentPermission = await Notifications.getPermissionsAsync();
if (hasNotificationPermission(currentPermission)) {
return;
}
const requestedPermission = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: false,
},
});
if (!hasNotificationPermission(requestedPermission)) {
throw {
code: 'host_error',
message: 'notification permission denied',
} satisfies HostBridgeError;
}
}
async function showLocalNotification(payload: unknown) {
const notification = normalizeHostBridgeLocalNotification(payload);
if (!notification) {
throw invalidRequest('title is required');
}
await ensureNotificationPermission();
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync(
HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
{
name: 'Genarrative',
importance: Notifications.AndroidImportance.DEFAULT,
},
);
}
await Notifications.scheduleNotificationAsync({
content: notification,
trigger:
Platform.OS === 'android'
? { channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID }
: null,
});
return true;
return showMobileLocalNotification(notification);
}
function getColorScheme() {
@@ -0,0 +1,71 @@
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import {
HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
type HostBridgeError,
type LocalNotificationPayload,
} from '../../../../packages/shared/src/contracts/hostBridge';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
function hasNotificationPermission(
permission: Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>,
) {
return (
permission.granted ||
permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL
);
}
async function ensureNotificationPermission() {
const currentPermission = await Notifications.getPermissionsAsync();
if (hasNotificationPermission(currentPermission)) {
return;
}
const requestedPermission = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: false,
},
});
if (!hasNotificationPermission(requestedPermission)) {
throw {
code: 'host_error',
message: 'notification permission denied',
} satisfies HostBridgeError;
}
}
export async function showMobileLocalNotification(
notification: LocalNotificationPayload,
) {
await ensureNotificationPermission();
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync(
HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID,
{
name: 'Genarrative',
importance: Notifications.AndroidImportance.DEFAULT,
},
);
}
await Notifications.scheduleNotificationAsync({
content: notification,
trigger:
Platform.OS === 'android'
? { channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID }
: null,
});
return true;
}
@@ -47,6 +47,7 @@
- 2026-06-18 宿主外观只读查询:新增 `appearance.getColorScheme` HostBridge capability,Expo 壳通过 React Native `Appearance.getColorScheme()` 读取系统配色,Tauri 壳通过主窗口 `theme()` 读取窗口主题;该能力只返回 `light` / `dark` / `unknown`,不设置 H5 主题、不覆盖系统主题,也不作为强制 UI 样式入口。
- 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capability,Expo 壳通过 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 平台细节。
- 2026-06-18 原生壳网络状态:新增 `network.status` 与 `network.statusChanged` HostBridge capability,Expo 壳通过 `expo-network` 查询和订阅真实系统网络状态,Tauri 壳从 `WEB_APP_ORIGIN` 解析主站 host / port 后做短超时 TCP 可达性查询,并通过 WebView `online` / `offline` 注入变化事件;H5 统一使用 `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`,不得直接读取 Expo / Tauri 私有网络 API。
- 2026-06-19 移动壳本地通知边界:Expo `notification.showLocal` 的权限确认、iOS 仅 alert 且不请求 badge/sound、Android 固定 channel、即时调度和通知 handler 统一收口在 `apps/mobile-shell/src/host-bridge/notifications.ts`;`dispatch.ts` 只负责 HostBridge payload 归一和调用 `showMobileLocalNotification(notification)`,不得直接调用 `expo-notifications` 调度或权限 API。移动壳配置检查会覆盖该模块结构、固定 channel 和即时调度形态,避免后续混入远程推送、后台推送或散落的本地通知实现。
- 2026-06-18 移动壳 WebView 状态重放:Expo WebView 每次同源主站页面成功加载后都会补发当前 `app.lifecycle` 与 `network.statusChanged` 状态,覆盖首载、受控刷新、H5 刷新和系统回收 WebView 进程后的新 JS 上下文;补发不新增 HostBridge capability,也不向 `about:blank`、外域或错误页注入宿主状态。
- 2026-06-18 桌面壳 WebView 状态重放:Tauri 主 WebView 每次页面加载完成后都会回放当前 `app.lifecycle` 并重新安装 `network.statusChanged` 监听脚本,覆盖托盘刷新、`app.reloadWebView` 和 H5 自刷新后的新 JS 上下文;桌面 runtime 同步声明 `host.events` 表示该真实事件通道可用,但仍不开放 Tauri event 插件或额外 command。
- 2026-06-18 桌面壳 H5 返回栈事件:Tauri 壳开始声明 `navigation.canGoBack`,但只通过固定注入脚本追踪当前 H5 文档内的 `pushState` / `replaceState` / `popstate` 路由栈并派发 HostBridge event;不把该能力实现为 request method,不开放 H5 到 Tauri 的 event 写入通道,也不声明跨文档 native back-forward list 真相。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -254,6 +254,7 @@ const expectedMobileHostBridgeFiles = [
'capabilities.ts',
'dispatch.ts',
'files.ts',
'notifications.ts',
'protocol.ts',
'scanner.ts',
'share.ts',