移动壳收紧外链打开检查

为移动壳外链打开增加系统可打开性校验

补充 HostBridge 外链失败测试与配置门禁

更新原生壳外链边界文档
This commit is contained in:
2026-06-18 21:26:59 +08:00
parent ddfdc409f3
commit 49b0f8c445
6 changed files with 41 additions and 2 deletions
@@ -1063,3 +1063,11 @@ if (mobileCapabilitySet.has('app.setBadgeCount')) {
if (!dispatchSource.includes('Appearance.getColorScheme()')) {
throw new Error('mobile shell HostBridge must read the native color scheme');
}
if (!dispatchSource.includes('Linking.canOpenURL(url)')) {
throw new Error('mobile shell HostBridge external URL flow must check Linking.canOpenURL');
}
if (!shellAppSource.includes('Linking.canOpenURL(externalUrl)')) {
throw new Error('mobile shell WebView external navigation must check Linking.canOpenURL');
}
@@ -141,6 +141,7 @@ vi.mock('expo-image-picker', () => ({
}));
vi.mock('expo-linking', () => ({
canOpenURL: vi.fn(),
openURL: vi.fn(),
}));
@@ -294,6 +295,8 @@ afterEach(() => {
expires: 'never',
});
vi.mocked(Linking.openURL).mockReset();
vi.mocked(Linking.canOpenURL).mockReset();
vi.mocked(Linking.canOpenURL).mockResolvedValue(true);
vi.mocked(Network.getNetworkStateAsync).mockReset();
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
type: Network.NetworkStateType.WIFI,
@@ -528,9 +531,26 @@ describe('handleMobileHostBridgeMessage', () => {
);
expectOk(response);
expect(Linking.canOpenURL).toHaveBeenCalledWith('https://example.com/path');
expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/path');
});
test('app.openExternalUrl 在系统不可打开时返回 host_error', async () => {
vi.mocked(Linking.canOpenURL).mockResolvedValue(false);
const response = await send(
request('app.openExternalUrl', {
url: 'tel:+12345678',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('host_error');
expect(failedResponse.error.message).toBe('external URL cannot be opened');
expect(Linking.openURL).not.toHaveBeenCalled();
});
test('app.openExternalUrl 拒绝危险协议', async () => {
const response = await send(
request('app.openExternalUrl', {
@@ -541,6 +561,7 @@ describe('handleMobileHostBridgeMessage', () => {
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('invalid_request');
expect(Linking.canOpenURL).not.toHaveBeenCalled();
expect(Linking.openURL).not.toHaveBeenCalled();
});
@@ -76,6 +76,13 @@ async function openExternalUrl(payload: unknown) {
throw invalidRequest('url must use an allowed external protocol');
}
if (!(await Linking.canOpenURL(url))) {
throw {
code: 'host_error',
message: 'external URL cannot be opened',
} satisfies HostBridgeError;
}
await Linking.openURL(url);
return true;
}
+3 -1
View File
@@ -215,7 +215,9 @@ export default function ShellApp() {
const externalUrl = resolveMobileShellExternalUrl(request.url);
if (externalUrl) {
void Linking.openURL(externalUrl).catch(() => undefined);
void Linking.canOpenURL(externalUrl)
.then((canOpen) => (canOpen ? Linking.openURL(externalUrl) : undefined))
.catch(() => undefined);
}
return false;
};
@@ -2336,6 +2336,7 @@
- 背景:Expo 移动壳和 Tauri 桌面壳都需要一个真实的宿主级刷新入口,供 H5 在检测到资源、登录态或运行态需要重新载入时请求宿主刷新当前容器;该能力不能演变成任意 URL 导航或原生 WebView ref 透传。
- 决策:新增 HostBridge method `app.reloadWebView` 和 H5 facade `reloadHostWebView()`。移动端只调用当前 `react-native-webview``reload()`,桌面端只调用 Tauri 主 `WebviewWindow.reload()`;该 method 不接受 payload,成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。继续把同源跳转留给 `navigation.openNativePage`,外链离开容器留给 `app.openExternalUrl`
- 2026-06-18 追加:Expo 移动壳的外链离开容器路径必须在协议白名单后再调用 `Linking.canOpenURL`;WebView 外域导航只有当前设备确认可打开时才调用 `Linking.openURL``app.openExternalUrl` 在系统不可打开时返回 `host_error`。该收紧不新增 HostBridge method,不把危险协议、相对路径或设备不可处理的外链留在带完整 HostBridge 的 WebView 内。
- 2026-06-18 追加:`AuthGate` 登录态身份边界刷新改为优先调用 `reloadHostWebView()`,用于登录成功、退出登录或从已登录变为未登录后的主站重新初始化;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`,普通 token refresh、账号资料更新、主题和音量变化仍不触发整页刷新。
- 2026-06-18 追加:Expo 移动壳的 iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 也复用当前 WebView 的受控 `reload()` 路径,系统回收 WebView 内容 / 渲染进程后只恢复同一 H5 容器,不改写 URL、不注入额外脚本、不新增宿主恢复页面。
- 2026-06-18 追加:Expo 移动壳的 `onError` / `onHttpError` 只对同源 H5 主页面展示原生加载失败兜底层,用户重试时仍复用当前 WebView `reload()`;兜底不接管外域、危险协议、`about:blank` 或 favicon 失败,也不向 H5 注入错误事件。
File diff suppressed because one or more lines are too long