补齐移动壳深链失败观测

移动壳 Deep Link 解析返回状态并记录拒绝路径

ShellApp 记录初始链接读取失败且保留安全入口

移动壳配置检查和文档同步深链失败边界
This commit is contained in:
2026-06-20 18:57:13 +08:00
parent 26f95cf398
commit fc9a557978
7 changed files with 166 additions and 17 deletions
+13 -1
View File
@@ -1385,7 +1385,10 @@ if (appConfig.extra?.genarrativeHostBridgeVersion !== sharedHostBridgeVersion) {
for (const snippet of [
'Linking.getInitialURL()',
"Linking.addEventListener('url'",
'buildMobileShellUrlFromDeepLink',
'resolveMobileShellUrlFromDeepLink',
'logMobileShellDeepLinkFailure',
"'initial_url.read'",
"logMobileShellDeepLinkFailure(`${source}.rejected`, url)",
'configureMobileHostBridgeNavigation',
'HOST_BRIDGE_PROTOCOL',
'HOST_BRIDGE_VERSION',
@@ -1805,6 +1808,11 @@ for (const snippet of [
}
for (const snippet of [
'MobileShellDeepLinkResolution',
"status: 'default'",
"status: 'mapped'",
"status: 'rejected'",
'resolveMobileShellUrlFromDeepLink',
'resolveMobileShellBaseWebUrl(baseWebUrl)',
'resolveTargetPath(rawUrl, webOrigin)',
'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)',
@@ -2497,6 +2505,10 @@ for (const snippet of [
'external WebView navigation native failures stay outside the WebView',
"expect(Linking.openURL).toHaveBeenCalledWith(",
"'mobile shell navigation failed for external_navigation.open'",
'initial deep link read failures are logged without replacing the current WebView URL',
"'mobile shell deep link failed for initial_url.read'",
'runtime deep link rejections are logged and fall back to a safe WebView URL',
"'mobile shell deep link failed for runtime_url.rejected'",
'expect.any(Error)',
]) {
if (!shellAppTestSource.includes(snippet)) {
+61 -1
View File
@@ -17,6 +17,7 @@ const shellHarness = vi.hoisted(() => {
const cameraViewProps = { current: null as Record<string, unknown> | null };
const injectJavaScriptError = { current: null as Error | null };
const injectedScripts = [] as string[];
const linkingUrlListeners = [] as Array<(event: { url: string }) => void>;
const networkListeners = [] as Array<(state: Record<string, unknown>) => void>;
return {
@@ -31,8 +32,10 @@ const shellHarness = vi.hoisted(() => {
cameraViewProps.current = null;
injectJavaScriptError.current = null;
injectedScripts.length = 0;
linkingUrlListeners.length = 0;
networkListeners.length = 0;
},
linkingUrlListeners,
webViewProps,
};
});
@@ -184,7 +187,10 @@ vi.mock('react-native', () => ({
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
},
Linking: {
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
addEventListener: vi.fn((_event, listener) => {
shellHarness.linkingUrlListeners.push(listener);
return { remove: vi.fn() };
}),
canOpenURL: vi.fn(async () => true),
getInitialURL: vi.fn(async () => null),
openURL: vi.fn(async () => undefined),
@@ -613,4 +619,58 @@ describe('ShellApp HostBridge event injection', () => {
warnSpy.mockRestore();
});
test('initial deep link read failures are logged without replacing the current WebView URL', async () => {
const { Linking } = await import('react-native');
const initialUrlError = new Error('initial URL unavailable');
vi.mocked(Linking.getInitialURL).mockRejectedValueOnce(initialUrlError);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
source?: { uri?: string };
};
const initialWebUrl = webViewProps.source?.uri;
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
'mobile shell deep link failed for initial_url.read',
initialUrlError,
);
});
expect(shellHarness.webViewProps.current?.source).toEqual({
uri: initialWebUrl,
});
warnSpy.mockRestore();
});
test('runtime deep link rejections are logged and fall back to a safe WebView URL', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const ShellApp = await importShellApp();
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
source?: { uri?: string };
};
const initialWebUrl = webViewProps.source?.uri;
shellHarness.linkingUrlListeners[0]?.({
url: 'https://outside.example/works/detail?work=PZ-1',
});
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
'mobile shell deep link failed for runtime_url.rejected',
'https://outside.example/works/detail?work=PZ-1',
);
});
expect(shellHarness.webViewProps.current?.source).toEqual({
uri: initialWebUrl,
});
warnSpy.mockRestore();
});
});
+28 -10
View File
@@ -25,7 +25,7 @@ import {
handleMobileHostBridgeMessage,
resolveMobileHostCapabilities,
} from '../host-bridge/bridge';
import { buildMobileShellUrlFromDeepLink } from './deepLink';
import { resolveMobileShellUrlFromDeepLink } from './deepLink';
import { lifecyclePayloadFromAppState } from './lifecycle';
import {
type MobileShellLoadFailure,
@@ -71,6 +71,10 @@ function logMobileShellNavigationFailure(label: string, error: unknown) {
console.warn(`mobile shell navigation failed for ${label}`, error);
}
function logMobileShellDeepLinkFailure(label: string, error: unknown) {
console.warn(`mobile shell deep link failed for ${label}`, error);
}
type MobileWebViewLoadErrorEvent = {
nativeEvent: {
url: string;
@@ -206,25 +210,39 @@ export default function ShellApp() {
useEffect(() => {
let disposed = false;
const openDeepLink = (url: string | null | undefined) => {
const nextUrl = buildMobileShellUrlFromDeepLink(
const openDeepLink = (
url: string | null | undefined,
source: 'initial_url' | 'runtime_url',
) => {
const resolution = resolveMobileShellUrlFromDeepLink(
url,
baseWebUrl,
urlOptions,
);
if (resolution.status === 'rejected') {
logMobileShellDeepLinkFailure(`${source}.rejected`, url);
}
resetNavigationCanGoBack();
setLoadFailure(null);
setWebUrl(nextUrl);
setWebUrl(resolution.url);
};
void Linking.getInitialURL().then((url) => {
if (!disposed) {
openDeepLink(url);
}
});
void Linking.getInitialURL()
.then((url) => {
if (!disposed) {
openDeepLink(url, 'initial_url');
}
})
.catch((error: unknown) => {
logMobileShellDeepLinkFailure('initial_url.read', error);
});
const subscription = Linking.addEventListener('url', (event) => {
openDeepLink(event.url);
try {
openDeepLink(event.url, 'runtime_url');
} catch (error) {
logMobileShellDeepLinkFailure('runtime_url.open', error);
}
});
return () => {
+29 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'vitest';
import { buildMobileShellUrlFromDeepLink } from './deepLink';
import {
buildMobileShellUrlFromDeepLink,
resolveMobileShellUrlFromDeepLink,
} from './deepLink';
const options = {
platform: 'ios' as const,
@@ -88,4 +91,29 @@ describe('buildMobileShellUrlFromDeepLink', () => {
expect(url.searchParams.get('work')).toBe('PZ-1');
expect(url.searchParams.get('clientRuntime')).toBe('native_app');
});
test('返回 deep link 解析状态用于壳层记录拒绝路径', () => {
const mapped = resolveMobileShellUrlFromDeepLink(
'genarrative://open/works/detail?work=PZ-1',
'https://app.genarrative.world/',
options,
);
const rejected = resolveMobileShellUrlFromDeepLink(
'https://example.com/works/detail?work=PZ-1',
'https://app.genarrative.world/',
options,
);
const empty = resolveMobileShellUrlFromDeepLink(
null,
'https://app.genarrative.world/',
options,
);
expect(mapped.status).toBe('mapped');
expect(new URL(mapped.url).pathname).toBe('/works/detail');
expect(rejected.status).toBe('rejected');
expect(new URL(rejected.url).pathname).toBe('/');
expect(empty.status).toBe('default');
expect(new URL(empty.url).pathname).toBe('/');
});
});
+27 -3
View File
@@ -6,6 +6,13 @@ import {
const supportedHosts = new Set(['open', 'app']);
type MobileShellDeepLinkResolutionStatus = 'default' | 'mapped' | 'rejected';
export type MobileShellDeepLinkResolution = {
status: MobileShellDeepLinkResolutionStatus;
url: string;
};
function extractPathFromNativeUrl(url: URL) {
if (supportedHosts.has(url.hostname)) {
return `${url.pathname}${url.search}${url.hash}`;
@@ -53,17 +60,34 @@ export function buildMobileShellUrlFromDeepLink(
baseWebUrl: string,
options: MobileShellUrlOptions,
) {
return resolveMobileShellUrlFromDeepLink(rawUrl, baseWebUrl, options).url;
}
export function resolveMobileShellUrlFromDeepLink(
rawUrl: string | null | undefined,
baseWebUrl: string,
options: MobileShellUrlOptions,
): MobileShellDeepLinkResolution {
const normalizedBaseWebUrl = resolveMobileShellBaseWebUrl(baseWebUrl);
const defaultUrl = buildMobileShellUrl(normalizedBaseWebUrl, options);
if (!rawUrl) {
return defaultUrl;
return {
status: 'default',
url: defaultUrl,
};
}
const webOrigin = new URL(normalizedBaseWebUrl).origin;
const targetPath = resolveTargetPath(rawUrl, webOrigin);
if (!targetPath) {
return defaultUrl;
return {
status: 'rejected',
url: defaultUrl,
};
}
return buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options);
return {
status: 'mapped',
url: buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options),
};
}
@@ -3079,6 +3079,13 @@
- 影响范围:`scripts/check-native-shells.mjs`
- 验证方式:`npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 移动壳 Deep Link 失败必须可观测
- 背景:Expo 移动壳已声明 `genarrative://` scheme、iOS associated domain 和 Android app link,冷启动 / 热启动 deep link 会决定用户是否落到作品详情、创作页或邀请码页;如果 `Linking.getInitialURL()` 读取失败或运行时 URL 被拒绝后静默回首页,真实安装包会表现为“能打开 App 但目标丢失”,排障也缺少证据。
- 决策:移动壳 deep link 解析必须返回 `default` / `mapped` / `rejected` 状态;外域、危险协议或非法路径继续回到安全默认主站入口,但必须记录拒绝日志。`Linking.getInitialURL()` reject 必须记录 `initial_url.read` 错误且不替换当前 WebView URL;运行时 URL 被拒绝必须记录 `runtime_url.rejected`,并继续落安全默认入口。配置检查反查 ShellApp 日志路径、deep link 状态 resolver 和对应测试。
- 影响范围:`apps/mobile-shell/src/shell/deepLink.ts``apps/mobile-shell/src/shell/ShellApp.tsx``apps/mobile-shell/scripts/check-config.mjs`、宿主壳方案文档。
- 验证方式:`npm run mobile-shell:typecheck``npm run mobile-shell:test -- src/shell/deepLink.test.ts src/shell/ShellApp.test.tsx``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 移动壳外链失败与扫码超时边界
- 背景:Expo 移动壳外链导航会离开带 HostBridge 的主 WebView,扫码能力也会打开原生相机 overlay;如果系统外链 API 异常被 helper 吞掉,或扫码 pending 没有共享超时清理,用户会看到点击无反应或后续扫码一直提示通道占用。
File diff suppressed because one or more lines are too long