新增 Expo 与 Tauri 原生宿主壳
新增 HostBridge 原生宿主契约和 H5 native_app transport 新增 Expo React Native 移动壳并收紧 WebView 外链边界 新增 Tauri 桌面壳并用 capability 收口受控命令 更新宿主壳方案、文档索引和共享记忆
This commit is contained in:
5
apps/mobile-shell/src/env.d.ts
vendored
Normal file
5
apps/mobile-shell/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare const process: {
|
||||
env: {
|
||||
EXPO_PUBLIC_GENARRATIVE_WEB_URL?: string;
|
||||
};
|
||||
};
|
||||
213
apps/mobile-shell/src/mobileHostBridge.ts
Normal file
213
apps/mobile-shell/src/mobileHostBridge.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as Linking from 'expo-linking';
|
||||
import { Platform, Share } from 'react-native';
|
||||
|
||||
import {
|
||||
type ClipboardWriteTextPayload,
|
||||
type HapticsImpactPayload,
|
||||
HOST_BRIDGE_PROTOCOL,
|
||||
HOST_BRIDGE_VERSION,
|
||||
type HostBridgeCapability,
|
||||
type HostBridgeError,
|
||||
type HostBridgeMethod,
|
||||
type HostBridgeRequest,
|
||||
type HostBridgeResponse,
|
||||
type OpenExternalUrlPayload,
|
||||
type ShareOpenPayload,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
|
||||
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
'host.getRuntime',
|
||||
'share.open',
|
||||
'share.setTarget',
|
||||
'app.openExternalUrl',
|
||||
'clipboard.writeText',
|
||||
'haptics.impact',
|
||||
];
|
||||
|
||||
let currentShareTarget: unknown = null;
|
||||
|
||||
function unsupported(method: HostBridgeMethod): HostBridgeError {
|
||||
return {
|
||||
code: 'unsupported_method',
|
||||
message: `${method} unsupported in mobile shell`,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidRequest(message: string): HostBridgeError {
|
||||
return {
|
||||
code: 'invalid_request',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function isHostBridgeRequest(value: unknown): value is HostBridgeRequest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<HostBridgeRequest>;
|
||||
return (
|
||||
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
|
||||
candidate.version === HOST_BRIDGE_VERSION &&
|
||||
typeof candidate.id === 'string' &&
|
||||
typeof candidate.method === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function parseRequest(raw: string) {
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ok<Result>(
|
||||
request: HostBridgeRequest,
|
||||
result?: Result,
|
||||
): HostBridgeResponse<Result> {
|
||||
return {
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
function failure(
|
||||
request: Pick<HostBridgeRequest, 'id'>,
|
||||
error: HostBridgeError,
|
||||
): HostBridgeResponse {
|
||||
return {
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
async function openExternalUrl(payload: unknown) {
|
||||
const url = (payload as OpenExternalUrlPayload | undefined)?.url;
|
||||
if (!url) {
|
||||
throw invalidRequest('url is required');
|
||||
}
|
||||
|
||||
await Linking.openURL(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function writeClipboard(payload: unknown) {
|
||||
const text = (payload as ClipboardWriteTextPayload | undefined)?.text;
|
||||
if (typeof text !== 'string') {
|
||||
throw invalidRequest('text is required');
|
||||
}
|
||||
|
||||
await Clipboard.setStringAsync(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runHaptics(payload: unknown) {
|
||||
const style = (payload as HapticsImpactPayload | undefined)?.style;
|
||||
const impactStyle =
|
||||
style === 'heavy'
|
||||
? Haptics.ImpactFeedbackStyle.Heavy
|
||||
: style === 'medium'
|
||||
? Haptics.ImpactFeedbackStyle.Medium
|
||||
: Haptics.ImpactFeedbackStyle.Light;
|
||||
|
||||
await Haptics.impactAsync(impactStyle);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openShare(payload: unknown) {
|
||||
const sharePayload =
|
||||
payload && typeof payload === 'object'
|
||||
? (payload as ShareOpenPayload)
|
||||
: currentShareTarget && typeof currentShareTarget === 'object'
|
||||
? (currentShareTarget as ShareOpenPayload)
|
||||
: undefined;
|
||||
const url = sharePayload?.url;
|
||||
const message = [sharePayload?.message, url].filter(Boolean).join('\n');
|
||||
|
||||
await Share.share({
|
||||
title: sharePayload?.title,
|
||||
message: message || url || sharePayload?.title || '',
|
||||
url,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleRequest(request: HostBridgeRequest) {
|
||||
switch (request.method) {
|
||||
case 'host.getRuntime':
|
||||
return ok(request, {
|
||||
shell: 'expo_mobile',
|
||||
platform: Platform.OS === 'ios' ? 'ios' : 'android',
|
||||
hostVersion: '0.1.0',
|
||||
bridgeVersion: HOST_BRIDGE_VERSION,
|
||||
capabilities: MOBILE_HOST_CAPABILITIES,
|
||||
});
|
||||
case 'app.openExternalUrl':
|
||||
return ok(request, await openExternalUrl(request.payload));
|
||||
case 'clipboard.writeText':
|
||||
return ok(request, await writeClipboard(request.payload));
|
||||
case 'haptics.impact':
|
||||
return ok(request, await runHaptics(request.payload));
|
||||
case 'share.open':
|
||||
return ok(request, await openShare(request.payload));
|
||||
case 'share.setTarget':
|
||||
currentShareTarget =
|
||||
request.payload && typeof request.payload === 'object'
|
||||
? (request.payload as { target?: unknown }).target
|
||||
: null;
|
||||
return ok(request, true);
|
||||
case 'auth.requestLogin':
|
||||
case 'payment.request':
|
||||
case 'navigation.openNativePage':
|
||||
return failure(request, unsupported(request.method));
|
||||
default:
|
||||
return failure(request, unsupported(request.method));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): HostBridgeError {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
'message' in error
|
||||
) {
|
||||
return error as HostBridgeError;
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'host_error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleMobileHostBridgeMessage(
|
||||
rawMessage: string,
|
||||
sendResponse: (response: HostBridgeResponse) => void,
|
||||
) {
|
||||
const parsed = parseRequest(rawMessage);
|
||||
if (!isHostBridgeRequest(parsed)) {
|
||||
sendResponse(
|
||||
failure(
|
||||
{ id: 'invalid' },
|
||||
invalidRequest('invalid host bridge request'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendResponse(await handleRequest(parsed));
|
||||
} catch (error) {
|
||||
sendResponse(failure(parsed, normalizeError(error)));
|
||||
}
|
||||
}
|
||||
36
apps/mobile-shell/src/mobileShellNavigation.test.ts
Normal file
36
apps/mobile-shell/src/mobileShellNavigation.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { shouldOpenInMobileShellWebView } from './mobileShellNavigation';
|
||||
|
||||
describe('shouldOpenInMobileShellWebView', () => {
|
||||
test('只允许主站同源页面留在移动壳 WebView 内', () => {
|
||||
const allowedOrigin = 'https://app.genarrative.world';
|
||||
|
||||
expect(
|
||||
shouldOpenInMobileShellWebView(
|
||||
'https://app.genarrative.world/works/detail?work=PZ-1',
|
||||
allowedOrigin,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenInMobileShellWebView('/creation/puzzle', allowedOrigin),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenInMobileShellWebView('about:blank', allowedOrigin),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('外链和非网页协议必须离开带 HostBridge 的 WebView', () => {
|
||||
const allowedOrigin = 'https://app.genarrative.world';
|
||||
|
||||
expect(
|
||||
shouldOpenInMobileShellWebView('https://example.com/', allowedOrigin),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldOpenInMobileShellWebView('mailto:hi@example.com', allowedOrigin),
|
||||
).toBe(false);
|
||||
expect(shouldOpenInMobileShellWebView('not a url', allowedOrigin)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
27
apps/mobile-shell/src/mobileShellNavigation.ts
Normal file
27
apps/mobile-shell/src/mobileShellNavigation.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function shouldOpenInMobileShellWebView(
|
||||
rawUrl: string,
|
||||
allowedOrigin: string,
|
||||
) {
|
||||
if (rawUrl === 'about:blank') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!rawUrl.startsWith('/') &&
|
||||
!rawUrl.startsWith('#') &&
|
||||
!/^[a-z][a-z0-9+.-]*:/i.test(rawUrl)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl, allowedOrigin);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return url.origin === allowedOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
26
apps/mobile-shell/src/mobileShellUrl.test.ts
Normal file
26
apps/mobile-shell/src/mobileShellUrl.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { buildMobileShellUrl } from './mobileShellUrl';
|
||||
|
||||
describe('buildMobileShellUrl', () => {
|
||||
test('为 H5 附加原生移动壳上下文', () => {
|
||||
const url = new URL(
|
||||
buildMobileShellUrl('https://app.test/works/detail?work=PZ-1', {
|
||||
platform: 'ios',
|
||||
hostVersion: '0.1.0',
|
||||
capabilities: ['host.getRuntime', 'share.open'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(url.searchParams.get('clientRuntime')).toBe('native_app');
|
||||
expect(url.searchParams.get('clientType')).toBe('native_app');
|
||||
expect(url.searchParams.get('hostShell')).toBe('expo_mobile');
|
||||
expect(url.searchParams.get('hostPlatform')).toBe('ios');
|
||||
expect(url.searchParams.get('hostVersion')).toBe('0.1.0');
|
||||
expect(url.searchParams.get('bridgeVersion')).toBe('1');
|
||||
expect(url.searchParams.get('hostCapabilities')).toBe(
|
||||
'host.getRuntime,share.open',
|
||||
);
|
||||
expect(url.searchParams.get('work')).toBe('PZ-1');
|
||||
});
|
||||
});
|
||||
25
apps/mobile-shell/src/mobileShellUrl.ts
Normal file
25
apps/mobile-shell/src/mobileShellUrl.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
HostBridgeCapability,
|
||||
NativeHostPlatform,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
|
||||
export type MobileShellUrlOptions = {
|
||||
platform: Extract<NativeHostPlatform, 'ios' | 'android'>;
|
||||
hostVersion: string;
|
||||
capabilities: HostBridgeCapability[];
|
||||
};
|
||||
|
||||
export function buildMobileShellUrl(
|
||||
rawUrl: string,
|
||||
options: MobileShellUrlOptions,
|
||||
) {
|
||||
const url = new URL(rawUrl);
|
||||
url.searchParams.set('clientRuntime', 'native_app');
|
||||
url.searchParams.set('clientType', 'native_app');
|
||||
url.searchParams.set('hostShell', 'expo_mobile');
|
||||
url.searchParams.set('hostPlatform', options.platform);
|
||||
url.searchParams.set('hostVersion', options.hostVersion);
|
||||
url.searchParams.set('bridgeVersion', '1');
|
||||
url.searchParams.set('hostCapabilities', options.capabilities.join(','));
|
||||
return url.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user