diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 5caccb1f2..2c036689f 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -993,6 +993,17 @@ function extractDesktopHandledMethods(source) { ]; } +function extractDesktopUnsupportedHandledMethods(source, handledMethods) { + return handledMethods.filter((method) => { + const methodBody = extractDesktopHostBridgeMethodBody(source, method); + return ( + methodBody.includes('"unsupported_method"') || + methodBody.includes('"unsupported_capability"') || + methodBody.includes('resolve_host_bridge_request(request)') + ); + }); +} + function extractDesktopHostBridgeMethodBody(source, method) { const methodStart = source.indexOf(`"${method}" =>`); if (methodStart < 0) { @@ -1001,7 +1012,7 @@ function extractDesktopHostBridgeMethodBody(source, method) { const nextMethodMatch = source .slice(methodStart + method.length) - .match(/\n "[^"]+"\s*=>/); + .match(/\n (?:"[^"]+"|_)\s*=>/); const nextMethodStart = nextMethodMatch ? methodStart + method.length + nextMethodMatch.index : -1; @@ -1518,9 +1529,35 @@ const desktopCapabilities = extractDesktopCapabilities( const desktopHandledMethods = extractDesktopHandledMethods( desktopHostBridgeDispatchSource, ); +const desktopUnsupportedHandledMethods = extractDesktopUnsupportedHandledMethods( + desktopHostBridgeDispatchSource, + desktopHandledMethods, +); const hostBridgeAcceptedUnsupportedMethods = sharedMethods.filter( (method) => !desktopCapabilities.includes(method), ); +const desktopCapabilityDelegationContracts = { + 'host.getRuntime': 'desktop_host_bridge_runtime_response(&request)', + 'appearance.getColorScheme': 'desktop_appearance_color_scheme(&app, &request)', + 'share.open': 'open_desktop_host_bridge_share(&app, &request)', + 'share.setTarget': 'set_desktop_host_bridge_share_target(&app, &request)', + 'navigation.openNativePage': 'open_desktop_host_bridge_native_page(&app, &request)', + 'app.reloadWebView': 'reload_desktop_host_bridge_webview(&app, &request)', + 'app.openExternalUrl': 'open_desktop_host_bridge_external_url(&app, &request)', + 'app.setTitle': 'set_desktop_host_bridge_window_title(&app, &request)', + 'app.setBadgeCount': 'set_desktop_app_badge_count(&app, &request)', + 'network.status': 'resolve_desktop_host_bridge_network_status(&request).await', + 'clipboard.writeText': 'write_desktop_host_bridge_clipboard_text(&app, &request)', + 'clipboard.readText': 'read_desktop_host_bridge_clipboard_text(&app, &request)', + 'file.exportText': 'export_desktop_host_bridge_text_file(&app, &request).await', + 'file.importText': 'import_desktop_host_bridge_text_file(&app, &request).await', + 'file.importDocument': 'import_desktop_host_bridge_document_file(&app, &request).await', + 'file.exportImage': 'export_desktop_host_bridge_image_file(&app, &request).await', + 'file.importImage': 'import_desktop_host_bridge_image_file(&app, &request).await', + 'file.importAudio': 'import_desktop_host_bridge_audio_file(&app, &request).await', + 'file.exportAudio': 'export_desktop_host_bridge_audio_file(&app, &request).await', + 'notification.showLocal': 'show_desktop_local_notification(&app, &request)', +}; if (desktopHostBridgeProtocol !== sharedHostBridgeProtocol) { throw new Error( `desktop shell HostBridge protocol drifted: expected ${sharedHostBridgeProtocol} but got ${desktopHostBridgeProtocol}`, @@ -1792,6 +1829,38 @@ if (missingDesktopMethodHandlers.length > 0) { ); } +const unsupportedDesktopCapabilities = desktopCapabilities.filter( + (capability) => + sharedMethods.includes(capability) && + desktopUnsupportedHandledMethods.includes(capability), +); +if (unsupportedDesktopCapabilities.length > 0) { + throw new Error( + `desktop shell declares request capabilities backed by fallback-only handlers: ${unsupportedDesktopCapabilities.join(', ')}`, + ); +} + +for (const capability of desktopCapabilities.filter((entry) => + sharedMethods.includes(entry), +)) { + const requiredDelegation = desktopCapabilityDelegationContracts[capability]; + if (!requiredDelegation) { + throw new Error( + `desktop shell declared request capability is missing a delegation contract: ${capability}`, + ); + } + + const methodBody = extractDesktopHostBridgeMethodBody( + desktopHostBridgeDispatchSource, + capability, + ); + if (!methodBody.includes(requiredDelegation)) { + throw new Error( + `desktop shell ${capability} must delegate to ${requiredDelegation}`, + ); + } +} + const undeclaredDesktopMethodHandlers = desktopHandledMethods.filter( (method) => !desktopCapabilities.includes(method) && diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 40e728082..a330c8d6e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3057,3 +3057,10 @@ - 决策:新增 `apps/mobile-shell/src/host-bridge/runtime.test.ts`,直接覆盖 iOS runtime 的 `hostVersion`、`bridgeVersion`、能力清单和 `app.setBadgeCount`,Android runtime 不声明 iOS 专属角标能力,以及 `host.getRuntime` HostBridge 成功响应形状;移动壳单端配置检查和根级原生壳门禁登记该测试文件并反查关键断言片段。该变更不新增 capability,不改变入口 query 或 H5 runtime 回读策略。 - 影响范围:`apps/mobile-shell/src/host-bridge/runtime.test.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。 - 验证方式:`npm run mobile-shell:test -- src/host-bridge/runtime.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 原生壳能力声明必须绑定真实链路 + +- 背景:桌面壳 capability profile 已声明一组 HostBridge request 能力,H5 平台入口也通过 `navigation.openNativePage` 打开 `/child-motion-demo` 这类受控内置玩法路由;如果门禁只检查“有 method case”或“文件被扫描”,未来可能退化成 fallback-only 分支或普通 Web 跳转而不被发现。 +- 决策:桌面壳配置检查必须反查每个已声明 request capability 对应的真实模块委托,并拒绝由 `unsupported_method`、`unsupported_capability` 或 fallback-only case 支撑的声明能力;根级 `check:native-shells` 新增 H5 native app route flow 合约,锁定 `/child-motion-demo` 的 `navigateHostNativePage` 调用、浏览器 fallback、路由表和命名交互测试。 +- 影响范围:`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档。 +- 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index c074e2e4c..c6fd48246 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -43,6 +43,8 @@ AI H5 sandbox 生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。 +声明为宿主请求能力的 desktop capability 必须在 `apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs` 命中真实模块委托,不能只由 `unsupported_method`、`unsupported_capability` 或 fallback 分支支撑;`apps/desktop-shell/scripts/check-config.mjs` 负责反查声明能力与委托函数的对应关系。H5 内置玩法如果通过 `navigation.openNativePage` 打开受控原生壳路由,也必须在 `scripts/check-native-shells.mjs` 登记 route flow、H5 fallback、路由表和命名交互测试,避免新增内置入口只停留在普通 Web 跳转。 + 桌面窗口状态持久化只属于 Tauri 宿主壳自身体验,不进入 HostBridge method 或 capability。`apps/desktop-shell/src-tauri/src/shell/window_state.rs` 必须用 Rust 单测证明只保存大小、位置和最大化状态,并排除可见性、全屏和装饰状态;`npm run check:native-shells` 会反查该测试边界。 桌面拖拽图片事件只在 Tauri 主窗口拖入真实有效图片时派发 `file.imageDropped`。目录、文本、损坏图片或没有任何有效图片的拖入不得生成 HostBridge payload,也不得把本地路径暴露给 H5;桌面壳配置检查会反查 `file_drop.rs` 的无效拖入单测。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index d53427c9b..589c5c23f 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -239,6 +239,47 @@ const wechatCapabilityFlowContracts = [ ], }, ]; +const h5NativeAppRouteFlowContracts = [ + { + route: '/child-motion-demo', + label: 'child motion demo', + files: [ + 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', + 'src/routing/appRoutes.tsx', + 'src/routing/appRoutes.test.ts', + 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', + ], + snippets: [ + [ + 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', + "navigateHostNativePage('/child-motion-demo')", + ], + [ + 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', + "window.location.assign('/child-motion-demo')", + ], + ['src/routing/appRoutes.tsx', "normalizedPath === '/child-motion-demo'"], + ['src/routing/appRoutes.test.ts', "matchAppRoute('/child-motion-demo')"], + [ + 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', + "request.method === 'navigation.openNativePage'", + ], + [ + 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', + "url === '/child-motion-demo'", + ], + ], + tests: [ + 'src/routing/appRoutes.test.ts', + ], + targetedTests: [ + { + filePath: 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', + name: 'native app opens child motion demo through host navigation bridge', + }, + ], + }, +]; const expectedWechatHostBridgeFiles = [ 'dispatch.js', 'payment.js', @@ -503,12 +544,21 @@ const h5HostBridgeTests = [ 'src/components/platform-entry/platformDraftGenerationShelfModel.test.ts', 'src/components/platform-entry/platformHostBridgeSync.test.ts', 'src/components/platform-entry/platformHostNotificationModel.test.ts', + 'src/routing/appRoutes.test.ts', 'src/services/runtimeAudioFeedback.test.ts', 'src/services/clipboard.test.ts', 'src/services/appTitle.test.ts', ]; const h5PlatformHostBridgeIntegrationTest = 'native app jump hop draft completion sends host notification and badge from platform shell'; +const h5NativeAppRouteFlowTestSteps = h5NativeAppRouteFlowContracts.flatMap( + (contract) => + (contract.targetedTests ?? []).map((test) => ({ + label: `h5-native-app-route-${contract.route}`, + command: npmCommand, + args: ['run', 'test', '--', test.filePath, '-t', test.name], + })), +); const wechatShellTests = [ 'miniprogram/host-bridge/protocol.test.js', @@ -543,6 +593,7 @@ const steps = [ h5PlatformHostBridgeIntegrationTest, ], }, + ...h5NativeAppRouteFlowTestSteps, { label: 'wechat-shell-tests', command: npmCommand, @@ -1926,6 +1977,51 @@ function assertWechatMiniProgramCapabilityFlows() { } } +function assertH5NativeAppRouteFlows() { + const h5HostBridgeTestSet = new Set(h5HostBridgeTests); + for (const contract of h5NativeAppRouteFlowContracts) { + for (const filePath of contract.files) { + if (!fs.existsSync(filePath)) { + throw new Error( + `H5 native app ${contract.label} route flow file is missing: ${filePath}`, + ); + } + } + + for (const [filePath, snippet] of contract.snippets) { + assertFileIncludesSnippet( + filePath, + snippet, + `H5 native app ${contract.label} route flow`, + ); + } + + for (const testPath of contract.tests) { + if (!h5HostBridgeTestSet.has(testPath)) { + throw new Error( + `H5 native app ${contract.label} route flow test is not part of check:native-shells: ${testPath}`, + ); + } + } + + for (const test of contract.targetedTests ?? []) { + assertFileIncludesSnippet( + test.filePath, + `test('${test.name}'`, + `H5 native app ${contract.label} route flow test`, + ); + const stepRunsTest = h5NativeAppRouteFlowTestSteps.some( + (step) => step.args.includes(test.filePath) && step.args.includes(test.name), + ); + if (!stepRunsTest) { + throw new Error( + `H5 native app ${contract.label} route flow targeted test is not part of check:native-shells: ${test.filePath}`, + ); + } + } + } +} + function assertWechatPaymentResultBoundaries() { const paymentSource = fs.readFileSync( 'miniprogram/host-bridge/payment.js', @@ -2316,6 +2412,9 @@ assertWechatMiniProgramRouteParity(); console.log('[check:native-shells] wechat-mini-program-capability-flows'); assertWechatMiniProgramCapabilityFlows(); +console.log('[check:native-shells] h5-native-app-route-flows'); +assertH5NativeAppRouteFlows(); + console.log('[check:native-shells] wechat-payment-result-boundaries'); assertWechatPaymentResultBoundaries();