From 3b3e83aa7aee67c9e09863fd361d18cc4230f202 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 08:19:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=9C=B0=E5=9D=80=E5=BD=92=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expo 移动壳只接受 http 和 https 基准 H5 地址 非法启动地址回退默认 H5 并继续附加宿主上下文 Deep link 继续限制为同源 H5 路径 补充启动地址检查测试和架构文档 --- apps/mobile-shell/App.tsx | 11 ++++-- apps/mobile-shell/scripts/check-config.mjs | 5 +++ .../src/mobileShellDeepLink.test.ts | 15 +++++++ apps/mobile-shell/src/mobileShellDeepLink.ts | 11 ++++-- apps/mobile-shell/src/mobileShellUrl.test.ts | 39 ++++++++++++++++++- apps/mobile-shell/src/mobileShellUrl.ts | 26 ++++++++++++- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + 8 files changed, 101 insertions(+), 9 deletions(-) diff --git a/apps/mobile-shell/App.tsx b/apps/mobile-shell/App.tsx index 3925d79c..7acf2659 100644 --- a/apps/mobile-shell/App.tsx +++ b/apps/mobile-shell/App.tsx @@ -25,9 +25,10 @@ import { } from './src/mobileShellNavigation'; import { subscribeMobileNetworkStatus } from './src/mobileShellNetwork'; import { MOBILE_SHELL_SAFE_AREA_EDGES } from './src/mobileShellSafeArea'; -import { buildMobileShellUrl } from './src/mobileShellUrl'; - -const defaultWebUrl = 'http://127.0.0.1:3000/'; +import { + buildMobileShellUrl, + resolveMobileShellBaseWebUrl, +} from './src/mobileShellUrl'; const hostVersion = '0.1.0'; function buildHostBridgeMessageScript(message: unknown) { @@ -39,7 +40,9 @@ function buildHostBridgeMessageScript(message: unknown) { export default function App() { const webViewRef = useRef(null); const [canGoBack, setCanGoBack] = useState(false); - const baseWebUrl = process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL || defaultWebUrl; + const baseWebUrl = resolveMobileShellBaseWebUrl( + process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL, + ); const mobileShellUrlOptions = useMemo( () => ({ platform: Platform.OS === 'ios' ? 'ios' as const : 'android' as const, diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index c5a49abf..7802e5f0 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -127,12 +127,17 @@ for (const snippet of [ 'SafeAreaProvider', 'SafeAreaView', 'MOBILE_SHELL_SAFE_AREA_EDGES', + 'resolveMobileShellBaseWebUrl', ]) { if (!appSource.includes(snippet)) { throw new Error(`mobile shell App missing ${snippet}`); } } +if (appSource.includes('process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL ||')) { + throw new Error('mobile shell App must normalize EXPO_PUBLIC_GENARRATIVE_WEB_URL'); +} + for (const dependency of [ 'expo-file-system', 'expo-document-picker', diff --git a/apps/mobile-shell/src/mobileShellDeepLink.test.ts b/apps/mobile-shell/src/mobileShellDeepLink.test.ts index 52ba9b33..62804b9d 100644 --- a/apps/mobile-shell/src/mobileShellDeepLink.test.ts +++ b/apps/mobile-shell/src/mobileShellDeepLink.test.ts @@ -73,4 +73,19 @@ describe('buildMobileShellUrlFromDeepLink', () => { expect(url.origin).toBe('https://app.genarrative.world'); expect(url.pathname).toBe('/'); }); + + test('基准 H5 URL 配置非法时回退到默认启动地址', () => { + const url = new URL( + buildMobileShellUrlFromDeepLink( + 'genarrative://open/works/detail?work=PZ-1', + 'file:///tmp/index.html', + options, + ), + ); + + expect(url.origin).toBe('http://127.0.0.1:3000'); + expect(url.pathname).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('PZ-1'); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + }); }); diff --git a/apps/mobile-shell/src/mobileShellDeepLink.ts b/apps/mobile-shell/src/mobileShellDeepLink.ts index e18524fc..24f908d1 100644 --- a/apps/mobile-shell/src/mobileShellDeepLink.ts +++ b/apps/mobile-shell/src/mobileShellDeepLink.ts @@ -1,4 +1,8 @@ -import { buildMobileShellUrl, type MobileShellUrlOptions } from './mobileShellUrl'; +import { + buildMobileShellUrl, + resolveMobileShellBaseWebUrl, + type MobileShellUrlOptions, +} from './mobileShellUrl'; const supportedHosts = new Set(['open', 'app']); @@ -49,12 +53,13 @@ export function buildMobileShellUrlFromDeepLink( baseWebUrl: string, options: MobileShellUrlOptions, ) { - const defaultUrl = buildMobileShellUrl(baseWebUrl, options); + const normalizedBaseWebUrl = resolveMobileShellBaseWebUrl(baseWebUrl); + const defaultUrl = buildMobileShellUrl(normalizedBaseWebUrl, options); if (!rawUrl) { return defaultUrl; } - const webOrigin = new URL(baseWebUrl).origin; + const webOrigin = new URL(normalizedBaseWebUrl).origin; const targetPath = resolveTargetPath(rawUrl, webOrigin); if (!targetPath) { return defaultUrl; diff --git a/apps/mobile-shell/src/mobileShellUrl.test.ts b/apps/mobile-shell/src/mobileShellUrl.test.ts index 69cfb33b..9e9684fd 100644 --- a/apps/mobile-shell/src/mobileShellUrl.test.ts +++ b/apps/mobile-shell/src/mobileShellUrl.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { buildMobileShellUrl } from './mobileShellUrl'; +import { + DEFAULT_MOBILE_SHELL_WEB_URL, + buildMobileShellUrl, + resolveMobileShellBaseWebUrl, +} from './mobileShellUrl'; describe('buildMobileShellUrl', () => { test('为 H5 附加原生移动壳上下文', () => { @@ -47,4 +51,37 @@ describe('buildMobileShellUrl', () => { 'host.getRuntime', ); }); + + test('移动壳基准 URL 只接受 http 和 https', () => { + expect(resolveMobileShellBaseWebUrl('https://app.test/path')).toBe( + 'https://app.test/path', + ); + expect(resolveMobileShellBaseWebUrl(' http://127.0.0.1:3000/ ')).toBe( + 'http://127.0.0.1:3000/', + ); + expect(resolveMobileShellBaseWebUrl('javascript:alert(1)')).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + expect(resolveMobileShellBaseWebUrl('/relative/path')).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + expect(resolveMobileShellBaseWebUrl('')).toBe(DEFAULT_MOBILE_SHELL_WEB_URL); + expect(resolveMobileShellBaseWebUrl(null)).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, + ); + }); + + test('非法启动 URL 回退到默认 H5 地址并继续附加宿主上下文', () => { + const url = new URL( + buildMobileShellUrl('javascript:alert(1)', { + platform: 'android', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime'], + }), + ); + + expect(url.toString().startsWith(DEFAULT_MOBILE_SHELL_WEB_URL)).toBe(true); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + expect(url.searchParams.get('hostShell')).toBe('expo_mobile'); + }); }); diff --git a/apps/mobile-shell/src/mobileShellUrl.ts b/apps/mobile-shell/src/mobileShellUrl.ts index 997b32c2..308df5d4 100644 --- a/apps/mobile-shell/src/mobileShellUrl.ts +++ b/apps/mobile-shell/src/mobileShellUrl.ts @@ -9,11 +9,35 @@ export type MobileShellUrlOptions = { capabilities: HostBridgeCapability[]; }; +export const DEFAULT_MOBILE_SHELL_WEB_URL = 'http://127.0.0.1:3000/'; + +export function resolveMobileShellBaseWebUrl(rawUrl: unknown) { + if (typeof rawUrl !== 'string') { + return DEFAULT_MOBILE_SHELL_WEB_URL; + } + + const value = rawUrl.trim(); + if (!value) { + return DEFAULT_MOBILE_SHELL_WEB_URL; + } + + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return DEFAULT_MOBILE_SHELL_WEB_URL; + } + + return url.toString(); + } catch { + return DEFAULT_MOBILE_SHELL_WEB_URL; + } +} + export function buildMobileShellUrl( rawUrl: string, options: MobileShellUrlOptions, ) { - const url = new URL(rawUrl); + const url = new URL(resolveMobileShellBaseWebUrl(rawUrl)); url.searchParams.set('clientRuntime', 'native_app'); url.searchParams.set('clientType', 'native_app'); url.searchParams.set('hostShell', 'expo_mobile'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0a0b00e7..e15171e9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -39,6 +39,7 @@ - 2026-06-18 移动图片拍摄导入:Expo 壳新增 `file.captureImage` HostBridge capability,通过 `expo-image-picker` 请求相机权限并打开系统相机拍摄图片,沿用 `file.importImage` 的 MIME、体积、base64 和文件名清洗规则,成功回传 `action=captured`,不暴露设备本地 URI,也不请求麦克风权限;Tauri 壳不声明该能力,不伪造桌面拍摄。 - 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时,主图上传和描述参考图上传可分别调用 `importHostImageFile()` / `captureHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `` 路径,不新增玩法侧上传分叉。 - 2026-06-18 移动壳安全区:Expo 壳根布局使用 `react-native-safe-area-context` 的 `SafeAreaProvider` 与四边 `SafeAreaView` 保护 WebView,避免 H5 主站内容贴进 iOS 刘海、底部 Home Indicator、Android 状态栏或横屏边缘;该能力属于宿主壳布局保护,不新增 H5 占位 UI,不改变玩法 runtime 或 HostBridge capability。 +- 2026-06-18 移动壳启动 URL 归一:Expo 壳的 `EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受 `http:` / `https:` 绝对 URL,空值、相对路径、`file:`、`javascript:` 等非法配置回退到默认 H5 地址后再附加 `native_app` 宿主上下文;deep link 仍只映射同源 H5 路径,禁止把外域或危险协议页面装进带完整 HostBridge 的 WebView。 - 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 或窗口失焦时暂停 `