收紧移动壳启动地址归一
Expo 移动壳只接受 http 和 https 基准 H5 地址 非法启动地址回退默认 H5 并继续附加宿主上下文 Deep link 继续限制为同源 H5 路径 补充启动地址检查测试和架构文档
This commit is contained in:
@@ -25,9 +25,10 @@ import {
|
|||||||
} from './src/mobileShellNavigation';
|
} from './src/mobileShellNavigation';
|
||||||
import { subscribeMobileNetworkStatus } from './src/mobileShellNetwork';
|
import { subscribeMobileNetworkStatus } from './src/mobileShellNetwork';
|
||||||
import { MOBILE_SHELL_SAFE_AREA_EDGES } from './src/mobileShellSafeArea';
|
import { MOBILE_SHELL_SAFE_AREA_EDGES } from './src/mobileShellSafeArea';
|
||||||
import { buildMobileShellUrl } from './src/mobileShellUrl';
|
import {
|
||||||
|
buildMobileShellUrl,
|
||||||
const defaultWebUrl = 'http://127.0.0.1:3000/';
|
resolveMobileShellBaseWebUrl,
|
||||||
|
} from './src/mobileShellUrl';
|
||||||
const hostVersion = '0.1.0';
|
const hostVersion = '0.1.0';
|
||||||
|
|
||||||
function buildHostBridgeMessageScript(message: unknown) {
|
function buildHostBridgeMessageScript(message: unknown) {
|
||||||
@@ -39,7 +40,9 @@ function buildHostBridgeMessageScript(message: unknown) {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const webViewRef = useRef<WebView>(null);
|
const webViewRef = useRef<WebView>(null);
|
||||||
const [canGoBack, setCanGoBack] = useState(false);
|
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(
|
const mobileShellUrlOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
platform: Platform.OS === 'ios' ? 'ios' as const : 'android' as const,
|
platform: Platform.OS === 'ios' ? 'ios' as const : 'android' as const,
|
||||||
|
|||||||
@@ -127,12 +127,17 @@ for (const snippet of [
|
|||||||
'SafeAreaProvider',
|
'SafeAreaProvider',
|
||||||
'SafeAreaView',
|
'SafeAreaView',
|
||||||
'MOBILE_SHELL_SAFE_AREA_EDGES',
|
'MOBILE_SHELL_SAFE_AREA_EDGES',
|
||||||
|
'resolveMobileShellBaseWebUrl',
|
||||||
]) {
|
]) {
|
||||||
if (!appSource.includes(snippet)) {
|
if (!appSource.includes(snippet)) {
|
||||||
throw new Error(`mobile shell App missing ${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 [
|
for (const dependency of [
|
||||||
'expo-file-system',
|
'expo-file-system',
|
||||||
'expo-document-picker',
|
'expo-document-picker',
|
||||||
|
|||||||
@@ -73,4 +73,19 @@ describe('buildMobileShellUrlFromDeepLink', () => {
|
|||||||
expect(url.origin).toBe('https://app.genarrative.world');
|
expect(url.origin).toBe('https://app.genarrative.world');
|
||||||
expect(url.pathname).toBe('/');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { buildMobileShellUrl, type MobileShellUrlOptions } from './mobileShellUrl';
|
import {
|
||||||
|
buildMobileShellUrl,
|
||||||
|
resolveMobileShellBaseWebUrl,
|
||||||
|
type MobileShellUrlOptions,
|
||||||
|
} from './mobileShellUrl';
|
||||||
|
|
||||||
const supportedHosts = new Set(['open', 'app']);
|
const supportedHosts = new Set(['open', 'app']);
|
||||||
|
|
||||||
@@ -49,12 +53,13 @@ export function buildMobileShellUrlFromDeepLink(
|
|||||||
baseWebUrl: string,
|
baseWebUrl: string,
|
||||||
options: MobileShellUrlOptions,
|
options: MobileShellUrlOptions,
|
||||||
) {
|
) {
|
||||||
const defaultUrl = buildMobileShellUrl(baseWebUrl, options);
|
const normalizedBaseWebUrl = resolveMobileShellBaseWebUrl(baseWebUrl);
|
||||||
|
const defaultUrl = buildMobileShellUrl(normalizedBaseWebUrl, options);
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
return defaultUrl;
|
return defaultUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const webOrigin = new URL(baseWebUrl).origin;
|
const webOrigin = new URL(normalizedBaseWebUrl).origin;
|
||||||
const targetPath = resolveTargetPath(rawUrl, webOrigin);
|
const targetPath = resolveTargetPath(rawUrl, webOrigin);
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
return defaultUrl;
|
return defaultUrl;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { buildMobileShellUrl } from './mobileShellUrl';
|
import {
|
||||||
|
DEFAULT_MOBILE_SHELL_WEB_URL,
|
||||||
|
buildMobileShellUrl,
|
||||||
|
resolveMobileShellBaseWebUrl,
|
||||||
|
} from './mobileShellUrl';
|
||||||
|
|
||||||
describe('buildMobileShellUrl', () => {
|
describe('buildMobileShellUrl', () => {
|
||||||
test('为 H5 附加原生移动壳上下文', () => {
|
test('为 H5 附加原生移动壳上下文', () => {
|
||||||
@@ -47,4 +51,37 @@ describe('buildMobileShellUrl', () => {
|
|||||||
'host.getRuntime',
|
'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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,11 +9,35 @@ export type MobileShellUrlOptions = {
|
|||||||
capabilities: HostBridgeCapability[];
|
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(
|
export function buildMobileShellUrl(
|
||||||
rawUrl: string,
|
rawUrl: string,
|
||||||
options: MobileShellUrlOptions,
|
options: MobileShellUrlOptions,
|
||||||
) {
|
) {
|
||||||
const url = new URL(rawUrl);
|
const url = new URL(resolveMobileShellBaseWebUrl(rawUrl));
|
||||||
url.searchParams.set('clientRuntime', 'native_app');
|
url.searchParams.set('clientRuntime', 'native_app');
|
||||||
url.searchParams.set('clientType', 'native_app');
|
url.searchParams.set('clientType', 'native_app');
|
||||||
url.searchParams.set('hostShell', 'expo_mobile');
|
url.searchParams.set('hostShell', 'expo_mobile');
|
||||||
|
|||||||
@@ -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 移动图片拍摄导入: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` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `<input type="file">` 路径,不新增玩法侧上传分叉。
|
- 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时,主图上传和描述参考图上传可分别调用 `importHostImageFile()` / `captureHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `<input type="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 移动壳安全区: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 桌面图片拖入接入主图槽位:`CreativeImageInputPanel` 在桌面壳声明 `file.imageDropped` 时订阅宿主拖入事件,只在拖入坐标命中当前主图卡片且未被上层元素遮挡时消费事件,避免窗口级拖入被多个创作面板同时接收;成功后仍转换为现有 `File` 上传回调。
|
||||||
- 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果,宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context;回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。
|
- 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果,宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context;回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。
|
||||||
- 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()`,`useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `<audio>` / WebAudio,回到 `active + focused` 后仅在运行态仍在播放、音源存在且用户音乐音量大于 0 时恢复,不改变用户音量设置。
|
- 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()`,`useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `<audio>` / WebAudio,回到 `active + focused` 后仅在运行态仍在播放、音源存在且用户音乐音量大于 0 时恢复,不改变用户音量设置。
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ GameBridge 禁止:
|
|||||||
|
|
||||||
2026-06-18 追加:移动壳根布局接入 `react-native-safe-area-context`,用 `SafeAreaProvider` 和四边 `SafeAreaView` 承接 iOS 刘海、底部 Home Indicator、Android 状态栏和横屏边缘安全区;WebView 仍加载同一 H5 主站,不改变 H5 路由、玩法 runtime 或 HostBridge capability。该能力属于宿主壳布局保护,不在 H5 内补额外占位 UI。
|
2026-06-18 追加:移动壳根布局接入 `react-native-safe-area-context`,用 `SafeAreaProvider` 和四边 `SafeAreaView` 承接 iOS 刘海、底部 Home Indicator、Android 状态栏和横屏边缘安全区;WebView 仍加载同一 H5 主站,不改变 H5 路由、玩法 runtime 或 HostBridge capability。该能力属于宿主壳布局保护,不在 H5 内补额外占位 UI。
|
||||||
|
|
||||||
|
2026-06-18 追加:移动壳启动 H5 URL 增加宿主侧归一。`EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受 `http:` / `https:` 绝对 URL,空值、相对路径、`file:`、`javascript:` 等非法配置统一回退到默认 H5 地址,再附加 `native_app` 宿主上下文;deep link 仍只允许映射到同源 H5 路径。该保护只防止移动壳启动崩溃或加载危险协议,不把外域页面装进带完整 HostBridge 的 WebView。
|
||||||
|
|
||||||
### Phase 3:Tauri 桌面壳 MVP
|
### Phase 3:Tauri 桌面壳 MVP
|
||||||
|
|
||||||
- 新增 `apps/desktop-shell/`。
|
- 新增 `apps/desktop-shell/`。
|
||||||
|
|||||||
Reference in New Issue
Block a user