From e51a65138a2a87984bebc3de2181cd547e1d40c7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 14:37:02 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=A7=BB=E5=8A=A8=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E9=A2=91=E9=81=93=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将移动端 Android 本地通知频道提升到共享 HostBridge 契约 让 Expo 通知调度和测试复用共享频道标识 增加移动壳配置门禁防止通知频道回退到本地字面量 补充宿主壳方案文档和共享决策记录 --- apps/mobile-shell/scripts/check-config.mjs | 26 ++++++++++++++++++- .../src/host-bridge/bridge.test.ts | 5 ++-- apps/mobile-shell/src/host-bridge/dispatch.ts | 7 +++-- .../shared-memory/decision-log.md | 4 +-- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +-- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- .../shared/src/contracts/hostBridge.test.ts | 7 +++++ packages/shared/src/contracts/hostBridge.ts | 2 ++ 8 files changed, 45 insertions(+), 12 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 80d91ba1e..20c5e8cd6 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -598,6 +598,10 @@ const sharedPublicWebUrl = extractStringConstExport( sharedContractSource, 'HOST_BRIDGE_PUBLIC_WEB_URL', ); +const sharedMobileLocalNotificationChannelId = extractStringConstExport( + sharedContractSource, + 'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID', +); const sharedNativeAppQueryKeys = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_NATIVE_APP_QUERY_KEYS', @@ -632,6 +636,7 @@ const sharedPayloadBoundaryImports = [ 'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES', 'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES', 'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES', + 'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID', 'normalizeHostBridgeQrCodeValue', 'HOST_BRIDGE_TEXT_MIME_TYPES', ]; @@ -664,6 +669,7 @@ const forbiddenLocalPayloadBoundaryDeclarations = [ 'HOST_BRIDGE_DOCUMENT_MIME_TYPES', 'HOST_BRIDGE_IMAGE_MIME_TYPES', 'HOST_BRIDGE_AUDIO_MIME_TYPES', + 'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID', ]; for (const localBoundary of forbiddenLocalPayloadBoundaryDeclarations) { if (new RegExp(`const ${localBoundary}\\s*=`).test(hostBridgeSource)) { @@ -1380,7 +1386,25 @@ for (const forbiddenHostBridgeRuntimeSnippet of ['atob(', 'Buffer.from']) { } if ( - !/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test( + dispatchSource.includes("const LOCAL_NOTIFICATION_CHANNEL_ID = '") || + dispatchSource.includes('"genarrative-local"') || + dispatchSource.includes("'genarrative-local'") +) { + throw new Error( + 'mobile shell local notification channel id must come from shared contract', + ); +} + +if (sharedMobileLocalNotificationChannelId !== 'genarrative-local') { + throw new Error('shared mobile local notification channel id drifted'); +} + +if (!dispatchSource.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, ) ) { diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 14df20ad8..2303b0e73 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -16,6 +16,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { HOST_BRIDGE_BADGE_COUNT_MAX, + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_RESPONSE_CACHE_MAX, HOST_BRIDGE_VERSION, @@ -738,7 +739,7 @@ describe('handleMobileHostBridgeMessage', () => { expectOk(response); expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith( - 'genarrative-local', + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, { name: 'Genarrative', importance: Notifications.AndroidImportance.DEFAULT, @@ -749,7 +750,7 @@ describe('handleMobileHostBridgeMessage', () => { title: '生成完成', }, trigger: { - channelId: 'genarrative-local', + channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, }, }); }); diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts index 29b33dd44..de900d9bd 100644 --- a/apps/mobile-shell/src/host-bridge/dispatch.ts +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -13,6 +13,7 @@ import { type ClipboardWriteTextPayload, type HapticsImpactPayload, HOST_BRIDGE_BADGE_COUNT_MAX, + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, HOST_BRIDGE_VERSION, type HostBridgeError, type HostBridgeRequest, @@ -51,8 +52,6 @@ import { resetQrScannerForTest, scanQrCode } from './scanner'; import { openShare } from './share'; import { buildMobileShellUrl } from '../shell/url'; -const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; - Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowBanner: true, @@ -193,7 +192,7 @@ async function showLocalNotification(payload: unknown) { await ensureNotificationPermission(); if (Platform.OS === 'android') { await Notifications.setNotificationChannelAsync( - LOCAL_NOTIFICATION_CHANNEL_ID, + HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, { name: 'Genarrative', importance: Notifications.AndroidImportance.DEFAULT, @@ -205,7 +204,7 @@ async function showLocalNotification(payload: unknown) { content: notification, trigger: Platform.OS === 'android' - ? { channelId: LOCAL_NOTIFICATION_CHANNEL_ID } + ? { channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID } : null, }); return true; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ecf631749..1d2533ae4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -70,8 +70,8 @@ - 2026-06-18 桌面图片拖入接入主图槽位:`CreativeImageInputPanel` 在桌面壳声明 `file.imageDropped` 时订阅宿主拖入事件,只在拖入坐标命中当前主图卡片且未被上层元素遮挡时消费事件,避免窗口级拖入被多个创作面板同时接收;成功后仍转换为现有 `File` 上传回调。 - 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果,宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context;回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。 - 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()`,`useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `