diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 83ce70d04..880d3aaed 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1063,9 +1063,40 @@ for (const capability of sdkBackedCapabilities) { } const allowedPermissions = [ - 'core:default', 'allow-host-bridge-request', ]; +const blockedCoreDefaultPermissions = [ + 'core:default', + 'core:app:default', + 'core:event:default', + 'core:image:default', + 'core:menu:default', + 'core:path:default', + 'core:resources:default', + 'core:tray:default', + 'core:webview:default', + 'core:window:default', +]; +const blockedCorePermissionPrefixes = [ + 'core:app:', + 'core:event:', + 'core:image:', + 'core:menu:', + 'core:path:', + 'core:resources:', + 'core:tray:', + 'core:webview:', + 'core:window:', +]; +const blockedPluginPermissionPrefixes = [ + 'clipboard-manager:', + 'deep-link:', + 'dialog:', + 'fs:', + 'notification:', + 'opener:', + 'window-state:', +]; const sharedTauriCommand = extractTsStringConst( sharedContractSource, 'HOST_BRIDGE_TAURI_COMMAND', @@ -1300,8 +1331,20 @@ assertSameList( allowedPermissions, 'desktop shell capability permissions', ); -if ((capability.permissions ?? []).some((permission) => permission.startsWith('notification:'))) { - throw new Error('desktop shell must not expose notification plugin commands to H5'); +for (const permission of capability.permissions ?? []) { + if (blockedCoreDefaultPermissions.includes(permission)) { + throw new Error(`desktop shell must not expose ${permission} to H5`); + } + if ( + blockedCorePermissionPrefixes.some((prefix) => permission.startsWith(prefix)) + ) { + throw new Error(`desktop shell must not expose Tauri core permission ${permission} to H5`); + } + if ( + blockedPluginPermissionPrefixes.some((prefix) => permission.startsWith(prefix)) + ) { + throw new Error(`desktop shell must not expose plugin permission ${permission} to H5`); + } } assertSameList( extractTauriBuildCommands(buildScript), diff --git a/apps/desktop-shell/src-tauri/capabilities/main.json b/apps/desktop-shell/src-tauri/capabilities/main.json index 35a4f8375..c0b6ea3f4 100644 --- a/apps/desktop-shell/src-tauri/capabilities/main.json +++ b/apps/desktop-shell/src-tauri/capabilities/main.json @@ -4,7 +4,6 @@ "description": "主窗口只开放 Genarrative 桌面宿主壳需要的受控命令。", "windows": ["main"], "permissions": [ - "core:default", "allow-host-bridge-request" ] } diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 0bd896ce4..b4d0b35f5 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -28,6 +28,10 @@ const hostBridgeSource = bridgeSourceFiles .join('\n'); const urlPath = new URL('../src/shell/url.ts', import.meta.url); const urlSource = fs.readFileSync(urlPath, 'utf8'); +const deepLinkPath = new URL('../src/shell/deepLink.ts', import.meta.url); +const deepLinkSource = fs.readFileSync(deepLinkPath, 'utf8'); +const navigationPath = new URL('../src/shell/navigation.ts', import.meta.url); +const navigationSource = fs.readFileSync(navigationPath, 'utf8'); const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url); const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8'); const webViewHistoryPath = new URL('../src/shell/webViewHistory.ts', import.meta.url); @@ -167,6 +171,17 @@ function extractStringConstExport(source, exportName) { return match[1]; } +function extractNumberConstExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return Number(match[1]); +} + function extractMobileBridgeHandledMethods(source) { const match = source.match( /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, @@ -464,6 +479,14 @@ const sharedMethods = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_METHODS', ); +const sharedHostBridgeProtocol = extractStringConstExport( + sharedContractSource, + 'HOST_BRIDGE_PROTOCOL', +); +const sharedHostBridgeVersion = extractNumberConstExport( + sharedContractSource, + 'HOST_BRIDGE_VERSION', +); const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource); const mobileCapabilities = extractStringArrayExport( hostBridgeSource, @@ -709,8 +732,8 @@ if ( throw new Error('mobile shell Android app link data must only bind https://app.genarrative.world'); } -if (appConfig.extra?.genarrativeHostBridgeVersion !== 1) { - throw new Error('mobile shell extra HostBridge version must be 1'); +if (appConfig.extra?.genarrativeHostBridgeVersion !== sharedHostBridgeVersion) { + throw new Error('mobile shell extra HostBridge version must match shared HostBridge version'); } for (const snippet of [ @@ -718,6 +741,8 @@ for (const snippet of [ "Linking.addEventListener('url'", 'buildMobileShellUrlFromDeepLink', 'configureMobileHostBridgeNavigation', + 'HOST_BRIDGE_PROTOCOL', + 'HOST_BRIDGE_VERSION', 'shouldAcceptMobileShellHostBridgeMessage', 'webViewRef.current?.reload()', 'const reloadCurrentWebView = useCallback(() => {', @@ -876,6 +901,40 @@ for (const snippet of [ } } +for (const snippet of [ + 'buildMobileShellUrl(', + 'HOST_BRIDGE_VERSION.toString()', + "url.searchParams.set('clientRuntime', 'native_app')", + "url.searchParams.set('hostShell', 'expo_mobile')", + "url.searchParams.set('hostPlatform', options.platform)", + "url.searchParams.set('bridgeVersion', HOST_BRIDGE_VERSION.toString())", + "url.searchParams.set('hostCapabilities', options.capabilities.join(','))", +]) { + if (!urlSource.includes(snippet)) { + throw new Error(`mobile shell host-context URL builder missing ${snippet}`); + } +} + +for (const snippet of [ + 'resolveMobileShellBaseWebUrl(baseWebUrl)', + 'resolveTargetPath(rawUrl, webOrigin)', + 'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)', +]) { + if (!deepLinkSource.includes(snippet)) { + throw new Error(`mobile shell deep link host-context flow missing ${snippet}`); + } +} + +for (const snippet of [ + 'resolveMobileShellWebViewUrl', + 'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)', + 'new URL(rawUrl, allowedOrigin).toString()', +]) { + if (!navigationSource.includes(snippet)) { + throw new Error(`mobile shell native-page navigation policy missing ${snippet}`); + } +} + if (shellAppSource.includes('127.0.0.1:3000')) { throw new Error( 'mobile shell ShellApp must not hard-code localhost as the default H5 URL', @@ -1014,6 +1073,7 @@ for (const snippet of [ 'inFlightHostBridgeResponses', 'resolveMobileHostBridgeResponse', 'rememberHostBridgeResponse', + 'buildMobileShellUrl(webViewUrl, navigation.urlOptions)', ]) { if (!hostBridgeSource.includes(snippet)) { throw new Error(`mobile shell HostBridge missing ${snippet}`); @@ -1044,6 +1104,18 @@ if ( throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source'); } +if (shellAppSource.includes(`bridge: '${sharedHostBridgeProtocol}'`)) { + throw new Error('mobile shell event injection must use HOST_BRIDGE_PROTOCOL'); +} + +if (shellAppSource.includes(`version: ${sharedHostBridgeVersion}`)) { + throw new Error('mobile shell event injection must use HOST_BRIDGE_VERSION'); +} + +if (urlSource.includes(`bridgeVersion', '${sharedHostBridgeVersion}'`)) { + throw new Error('mobile shell URL builder must use HOST_BRIDGE_VERSION'); +} + for (const capability of sdkBackedCapabilities) { if ( shellAppSource.includes(`'${capability}'`) || diff --git a/apps/mobile-shell/scripts/check-expo-config.mjs b/apps/mobile-shell/scripts/check-expo-config.mjs index a64becb1f..d4da0f8c0 100644 --- a/apps/mobile-shell/scripts/check-expo-config.mjs +++ b/apps/mobile-shell/scripts/check-expo-config.mjs @@ -4,10 +4,15 @@ import fs from 'node:fs'; const appConfigPath = new URL('../app.json', import.meta.url); const packagePath = new URL('../package.json', import.meta.url); +const sharedContractPath = new URL( + '../../../packages/shared/src/contracts/hostBridge.ts', + import.meta.url, +); const shellRoot = new URL('../', import.meta.url); const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo; const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8'); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const result = spawnSync( @@ -81,6 +86,22 @@ function findPlugin(name) { ); } +function extractNumberConstExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return Number(match[1]); +} + +const sharedHostBridgeVersion = extractNumberConstExport( + sharedContractSource, + 'HOST_BRIDGE_VERSION', +); + assertEqual(expoConfig.name, 'Genarrative', 'name'); assertEqual(expoConfig.slug, 'genarrative-mobile-shell', 'slug'); assertEqual(expoConfig.scheme, 'genarrative', 'scheme'); @@ -105,7 +126,7 @@ if ('releaseChannel' in expoConfig || 'channel' in expoConfig) { } assertEqual( expoConfig.extra?.genarrativeHostBridgeVersion, - 1, + sharedHostBridgeVersion, 'HostBridge version', ); diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 01f27e501..ed12dac67 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -15,6 +15,10 @@ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import type { WebViewMessageEvent } from 'react-native-webview'; import { WebView } from 'react-native-webview'; +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, +} from '../../../../packages/shared/src/contracts/hostBridge'; import { configureMobileHostBridgeNavigation, handleMobileHostBridgeMessage, @@ -97,8 +101,8 @@ export default function ShellApp() { const injectHostBridgeEvent = useCallback((event: string, payload: unknown) => { webViewRef.current?.injectJavaScript( buildHostBridgeMessageScript({ - bridge: 'GenarrativeHostBridge', - version: 1, + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, event, payload, }), diff --git a/apps/mobile-shell/src/shell/url.test.ts b/apps/mobile-shell/src/shell/url.test.ts index efd10ffa2..5fcd3fe56 100644 --- a/apps/mobile-shell/src/shell/url.test.ts +++ b/apps/mobile-shell/src/shell/url.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; +import { HOST_BRIDGE_VERSION } from '../../../../packages/shared/src/contracts/hostBridge'; import { DEFAULT_MOBILE_SHELL_WEB_URL, buildMobileShellUrl, @@ -25,7 +26,9 @@ describe('buildMobileShellUrl', () => { expect(url.searchParams.get('hostShell')).toBe('expo_mobile'); expect(url.searchParams.get('hostPlatform')).toBe('ios'); expect(url.searchParams.get('hostVersion')).toBe('0.1.0'); - expect(url.searchParams.get('bridgeVersion')).toBe('1'); + expect(url.searchParams.get('bridgeVersion')).toBe( + HOST_BRIDGE_VERSION.toString(), + ); expect(url.searchParams.get('hostCapabilities')).toBe( 'host.getRuntime,share.open', ); diff --git a/apps/mobile-shell/src/shell/url.ts b/apps/mobile-shell/src/shell/url.ts index 33e14e560..a1251fcd7 100644 --- a/apps/mobile-shell/src/shell/url.ts +++ b/apps/mobile-shell/src/shell/url.ts @@ -1,6 +1,7 @@ -import type { - HostBridgeCapability, - NativeHostPlatform, +import { + HOST_BRIDGE_VERSION, + type HostBridgeCapability, + type NativeHostPlatform, } from '../../../../packages/shared/src/contracts/hostBridge'; export type MobileShellUrlOptions = { @@ -58,7 +59,7 @@ export function buildMobileShellUrl( url.searchParams.set('hostShell', 'expo_mobile'); url.searchParams.set('hostPlatform', options.platform); url.searchParams.set('hostVersion', options.hostVersion); - url.searchParams.set('bridgeVersion', '1'); + url.searchParams.set('bridgeVersion', HOST_BRIDGE_VERSION.toString()); url.searchParams.set('hostCapabilities', options.capabilities.join(',')); return url.toString(); } diff --git a/apps/mobile-shell/src/shell/webViewHistory.test.ts b/apps/mobile-shell/src/shell/webViewHistory.test.ts index af60e2cac..89d7b8c25 100644 --- a/apps/mobile-shell/src/shell/webViewHistory.test.ts +++ b/apps/mobile-shell/src/shell/webViewHistory.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'vitest'; +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, +} from '../../../../packages/shared/src/contracts/hostBridge'; import { parseMobileWebViewHistoryStateMessage } from './webViewHistory'; describe('parseMobileWebViewHistoryStateMessage', () => { @@ -31,8 +35,8 @@ describe('parseMobileWebViewHistoryStateMessage', () => { expect( parseMobileWebViewHistoryStateMessage( JSON.stringify({ - bridge: 'GenarrativeHostBridge', - version: 1, + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, method: 'host.getRuntime', }), ), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index afcef2b58..4acd95a25 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -51,6 +51,8 @@ - 2026-06-18 移动壳键盘布局:Expo Android 壳 `softwareKeyboardLayoutMode` 固定为 `resize`,让系统键盘打开时真实调整 WebView 可视高度;H5 继续使用已有 viewport / 输入法聚焦适配承接创作表单、聊天输入和玩法输入框,壳层不新增键盘遮挡补偿 UI、不伪造键盘状态。移动壳配置检查和 Expo public config smoke 会拒绝该字段缺失或漂移。 - 2026-06-18 移动壳媒体策略:Expo WebView 允许内联媒体播放和用户触发的全屏视频,但保留 `mediaPlaybackRequiresUserAction`,不允许无手势自动播放;固定玩法和 AI H5 sandbox 的音频仍由 H5 用户开关、运行态状态和宿主生命周期控制,壳层不注入额外播放器或假播放状态。移动壳配置检查会拒绝 WebView 媒体策略漂移。 - 2026-06-18 移动壳启动 URL 归一:Expo 壳的 `EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受生产主站 `https://app.genarrative.world`,以及本机开发联调 `http://127.0.0.1`、`http://localhost`、`http://[::1]`;空值、相对路径、外域、`file:`、`javascript:` 等非法配置回退到默认 H5 地址后再附加 `native_app` 宿主上下文;deep link 仍只映射归一后基准 origin 的 H5 路径,禁止把外域或危险协议页面装进带完整 HostBridge 的 WebView。 +- 2026-06-18 移动壳主动导航上下文:Expo 壳的 `navigation.openNativePage` 与 deep link 都必须复用 `buildMobileShellUrl(...)` 补写 `native_app`、`expo_mobile`、真实平台、版本和 capability 清单;受控导航只接受当前允许 origin 的同源 H5 URL。移动壳配置检查会拒绝主动导航或 deep link 绕过该宿主上下文构造入口。 +- 2026-06-18 移动壳协议常量来源:Expo 壳的 HostBridge 事件注入、入口 URL `bridgeVersion`、`host.getRuntime` 回包和 Expo public config smoke 必须使用 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`,不得在壳层重新写死协议名或版本字面量;配置检查会拒绝这些常量漂移。 - 2026-06-18 移动壳默认入口:Expo 壳默认 H5 地址固定为 `https://app.genarrative.world/`,开发联调本机 Vite 必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/`、`http://localhost:3000/` 或 `http://[::1]:3000/`;生产包不得在未配置环境变量时加载设备本机 localhost,也不得通过环境变量把第三方外域 H5 放入带完整 HostBridge 的 WebView。 - 2026-06-18 移动壳安装包身份:Expo 移动壳的 iOS bundle identifier 与 Android package 统一固定为 `world.genarrative.mobile`,应用版本固定为 `0.1.0`,iOS `buildNumber` 从字符串 `"1"` 起步,Android `versionCode` 从整数 `1` 起步;后续分发安装包时递增构建号 / versionCode,产品版本号按发布节奏调整。移动壳配置检查会校验 `app.json` 与 `package.json` 版本一致,并拒绝缺失或漂移的包标识,当前不写入假商店元数据、假更新端点或占位渠道 SDK 配置。 - 2026-06-18 移动壳 HostBridge 版本单一来源:Expo 移动壳的 H5 入口 query 和 `host.getRuntime` 回包都读取 `MOBILE_SHELL_HOST_VERSION`,该常量必须与 `app.json` / `package.json` 版本一致;配置检查会拒绝 `App.tsx` 或 `bridge.ts` 重新散落硬编码版本,避免安装包版本升级时 H5 首屏上下文与 runtime 回读分叉。 @@ -77,6 +79,7 @@ - 2026-06-18 移动壳文件 bytes 校验:Expo 图片 / 音频导入导出不得只信系统 picker 返回 MIME、文件扩展名或 H5 声明 MIME;移动壳必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM base64 bytes 头部,要求导入 MIME 归一结果与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。移动图片导出还必须按 MIME 给系统分享 / 保存面板补齐 `.png` / `.jpg` / `.webp` 文件名扩展,避免缓存文件名与真实图片类型漂移。配置检查和移动壳测试覆盖该边界。 - 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。 - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 +- 2026-06-18 桌面壳 capability 最小化:Tauri 主窗口 capability 只授予 `allow-host-bridge-request`,不得授予 `core:default`、`core:*:default`、任意 core 子权限或 dialog / fs / notification / opener / clipboard / deep-link / window-state 等插件权限。窗口、菜单、托盘、剪贴板、文件、通知和外链能力只能由 Rust 壳内部调用,再经 `host_bridge_request` 分发。 - 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 - 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。 - 2026-06-18 HostBridge method 白名单跨壳门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳 HostBridge 分发不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再落两端壳实现或明确 unsupported。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 652d3ec5f..fb7448cf9 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -182,6 +182,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。 - H5 通过 `window.__TAURI__.core.invoke('host_bridge_request', request)` 或后续封装的 `nativeAppHostBridge` 调用桌面能力。 - Rust 侧只暴露一个受控 `host_bridge_request` command,再在 Rust 内部按 method 白名单分发。 - Tauri capabilities 只授予主窗口所需命令;默认不开放文件系统、shell、全局剪贴板或任意插件能力。 +- 主窗口 capability 只授予 `allow-host-bridge-request`;不得使用 `core:default`、`core:*:default` 或插件 command 权限作为兜底。窗口、菜单、托盘、剪贴板、文件、通知和外链能力只能由 Rust 壳内部调用,再通过 `host_bridge_request` 的 HostBridge method 白名单分发给 H5。 - 桌面支付首期走现有 H5 / 二维码 / 外部浏览器路径,不在 Rust 侧保存支付凭据。 - 文件导出、作品卡保存、图片拖拽导入、应用菜单、系统托盘、自动更新等桌面能力按后续需求逐项开放;其中应用菜单和系统托盘属于桌面壳自有能力,不作为 HostBridge method 暴露给 H5。 - 主 WebView 默认拒绝网页自动下载或 `` 触发的落盘动作;用户保存文本、图片、音频等内容必须走已声明的 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method,由 Rust 侧执行 MIME、大小、文件名清洗和系统保存对话框确认。 @@ -319,6 +320,10 @@ GameBridge 禁止: 2026-06-18 追加:移动壳启动 H5 URL 增加宿主侧归一。`EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受生产主站 `https://app.genarrative.world`,以及本机开发联调的 `http://127.0.0.1`、`http://localhost`、`http://[::1]` 入口;空值、相对路径、外域、`file:`、`javascript:` 等非法配置统一回退到默认 H5 地址,再附加 `native_app` 宿主上下文。deep link 仍只允许映射到归一后基准 origin 的 H5 路径,避免外域页面被装进带完整 HostBridge 的 WebView。 +2026-06-18 追加:移动壳 `navigation.openNativePage` 与 deep link 共享同一宿主上下文补写口径。受控主动导航先解析为当前允许 origin 的同源 H5 URL,再通过 `buildMobileShellUrl(...)` 重新附加 `clientRuntime=native_app`、`hostShell=expo_mobile`、当前平台、版本和真实 capability 清单;deep link 同样在同源路径归一后复用该构造函数。`apps/mobile-shell/scripts/check-config.mjs` 会拒绝主动导航或 deep link 绕过宿主上下文构造入口,避免新页面按普通浏览器运行态启动。 + +2026-06-18 追加:移动壳 HostBridge 协议名和协议版本统一从 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION` 读取。Expo 入口 query、WebView 事件注入、`host.getRuntime` 回包和 Expo public config smoke 都必须反查共享常量;移动壳配置检查会拒绝重新写死 `GenarrativeHostBridge` 或字面量版本。 + 2026-06-18 追加:移动壳默认 H5 地址固定为 `https://app.genarrative.world/`。开发联调如需加载本机 Vite,必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/`、`http://localhost:3000/` 或 `http://[::1]:3000/`;生产包不得在未配置环境变量时默认加载设备本机 localhost,也不得通过环境变量把第三方外域 H5 放入带完整 HostBridge 的 WebView。 2026-06-18 追加:移动壳系统深链声明固定为生产主站唯一入口。iOS `associatedDomains` 只能包含 `applinks:app.genarrative.world`;Android `intentFilters` 只能存在一个 `VIEW` / `autoVerify=true` 的 App Link 过滤器,category 只能是 `BROWSABLE` 和 `DEFAULT`,data 只能绑定 `https://app.genarrative.world`,不得额外声明外域、明文协议、pathPattern 或其它可接管范围。实际 deep link 解析仍由壳层把同源路径归一后附加 HostBridge 上下文,非法来源回退默认首页。 @@ -348,6 +353,8 @@ GameBridge 禁止: 2026-06-18 追加:桌面壳当前真实能力完整清单为 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`app.setTitle`、`app.setBadgeCount`、`network.status`、`network.statusChanged`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.importText`、`file.exportImage`、`file.importImage`、`file.importAudio`、`file.exportAudio`、`file.imageDropped` 和 `notification.showLocal`。`npm run check:native-shells` 会从 Rust capability 清单反查本段,避免实现、入口 URL 和方案文档再次漂移。 +2026-06-18 追加:桌面壳主窗口 capability 最小化为 `allow-host-bridge-request`。Tauri `core:default` 会同时包含 event、window、webview、menu、tray 等默认命令集合,其中 webview 默认集包含 DevTools 内部切换命令;当前壳不再授予这些 core / plugin JS 权限。`apps/desktop-shell/scripts/check-config.mjs` 会拒绝 `core:default`、`core:*:default`、任意 core 子权限和 dialog / fs / notification / opener / clipboard / deep-link / window-state 等插件权限进入主窗口 capability。 + 2026-06-18 追加:桌面壳的网络状态同样只通过 HostBridge 暴露给 H5。H5 外部生成队列概览在桌面壳明确离线或不可达时暂停轮询,窗口恢复在线后重新刷新;Tauri 仍不开放任意网络探测、shell 或文件系统能力给主站。 2026-06-18 追加:桌面壳声明并实现 `file.importText`,通过系统文件选择框读取用户选择的文本文件,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露本机绝对路径,也不开放通用文件系统。H5 创作 Agent 工作台复用同一 HostBridge 文本导入入口和后端解析接口;普通浏览器、小程序和未声明该能力的裁剪壳继续保留原 `` 路径。