接入 Expo 移动壳深链入口
新增移动壳 deep link 到同源 H5 路径的解析与运行时监听 配置移动壳真实品牌图标、iOS associated domain 和 Android app link 补充移动壳配置守门、单测和宿主壳文档记忆
This commit is contained in:
76
apps/mobile-shell/src/mobileShellDeepLink.test.ts
Normal file
76
apps/mobile-shell/src/mobileShellDeepLink.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { buildMobileShellUrlFromDeepLink } from './mobileShellDeepLink';
|
||||
|
||||
const options = {
|
||||
platform: 'ios' as const,
|
||||
hostVersion: '0.1.0',
|
||||
capabilities: ['host.getRuntime' as const],
|
||||
};
|
||||
|
||||
describe('buildMobileShellUrlFromDeepLink', () => {
|
||||
test('把原生 scheme deep link 映射成同源 H5 目标页', () => {
|
||||
const url = new URL(
|
||||
buildMobileShellUrlFromDeepLink(
|
||||
'genarrative://open/works/detail?work=PZ-1',
|
||||
'https://app.genarrative.world/',
|
||||
options,
|
||||
),
|
||||
);
|
||||
|
||||
expect(url.origin).toBe('https://app.genarrative.world');
|
||||
expect(url.pathname).toBe('/works/detail');
|
||||
expect(url.searchParams.get('work')).toBe('PZ-1');
|
||||
expect(url.searchParams.get('clientRuntime')).toBe('native_app');
|
||||
expect(url.searchParams.get('hostShell')).toBe('expo_mobile');
|
||||
});
|
||||
|
||||
test('把同源 universal link 映射成带宿主上下文的 H5 URL', () => {
|
||||
const url = new URL(
|
||||
buildMobileShellUrlFromDeepLink(
|
||||
'https://app.genarrative.world/creation/puzzle#draft',
|
||||
'https://app.genarrative.world/',
|
||||
options,
|
||||
),
|
||||
);
|
||||
|
||||
expect(url.pathname).toBe('/creation/puzzle');
|
||||
expect(url.hash).toBe('#draft');
|
||||
expect(url.searchParams.get('hostCapabilities')).toBe('host.getRuntime');
|
||||
});
|
||||
|
||||
test('外域和危险协议回退到默认首页', () => {
|
||||
const external = new URL(
|
||||
buildMobileShellUrlFromDeepLink(
|
||||
'https://example.com/works/detail?work=PZ-1',
|
||||
'https://app.genarrative.world/',
|
||||
options,
|
||||
),
|
||||
);
|
||||
const unsafe = new URL(
|
||||
buildMobileShellUrlFromDeepLink(
|
||||
'javascript:alert(1)',
|
||||
'https://app.genarrative.world/',
|
||||
options,
|
||||
),
|
||||
);
|
||||
|
||||
expect(external.origin).toBe('https://app.genarrative.world');
|
||||
expect(external.pathname).toBe('/');
|
||||
expect(unsafe.origin).toBe('https://app.genarrative.world');
|
||||
expect(unsafe.pathname).toBe('/');
|
||||
});
|
||||
|
||||
test('协议相对外域 URL 不会被装进移动壳 WebView', () => {
|
||||
const url = new URL(
|
||||
buildMobileShellUrlFromDeepLink(
|
||||
'//example.com/works/detail?work=PZ-1',
|
||||
'https://app.genarrative.world/',
|
||||
options,
|
||||
),
|
||||
);
|
||||
|
||||
expect(url.origin).toBe('https://app.genarrative.world');
|
||||
expect(url.pathname).toBe('/');
|
||||
});
|
||||
});
|
||||
64
apps/mobile-shell/src/mobileShellDeepLink.ts
Normal file
64
apps/mobile-shell/src/mobileShellDeepLink.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { buildMobileShellUrl, type MobileShellUrlOptions } from './mobileShellUrl';
|
||||
|
||||
const supportedHosts = new Set(['open', 'app']);
|
||||
|
||||
function extractPathFromNativeUrl(url: URL) {
|
||||
if (supportedHosts.has(url.hostname)) {
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
return `${url.hostname ? `/${url.hostname}` : ''}${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function resolveTargetPath(rawUrl: string, webOrigin: string) {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.protocol === 'genarrative:') {
|
||||
return extractPathFromNativeUrl(url);
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.origin !== webOrigin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
if (!rawUrl.startsWith('/') && !rawUrl.startsWith('#')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const relativeUrl = new URL(rawUrl, webOrigin);
|
||||
if (relativeUrl.origin !== webOrigin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${relativeUrl.pathname}${relativeUrl.search}${relativeUrl.hash}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMobileShellUrlFromDeepLink(
|
||||
rawUrl: string | null | undefined,
|
||||
baseWebUrl: string,
|
||||
options: MobileShellUrlOptions,
|
||||
) {
|
||||
const defaultUrl = buildMobileShellUrl(baseWebUrl, options);
|
||||
if (!rawUrl) {
|
||||
return defaultUrl;
|
||||
}
|
||||
|
||||
const webOrigin = new URL(baseWebUrl).origin;
|
||||
const targetPath = resolveTargetPath(rawUrl, webOrigin);
|
||||
if (!targetPath) {
|
||||
return defaultUrl;
|
||||
}
|
||||
|
||||
return buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options);
|
||||
}
|
||||
Reference in New Issue
Block a user