补齐移动壳深链失败观测
移动壳 Deep Link 解析返回状态并记录拒绝路径 ShellApp 记录初始链接读取失败且保留安全入口 移动壳配置检查和文档同步深链失败边界
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user