diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 7963a8b41..397895415 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -8,6 +8,20 @@ const appPath = new URL('../App.tsx', import.meta.url); const appSource = fs.readFileSync(appPath, 'utf8'); const bridgePath = new URL('../src/host-bridge/mobileHostBridge.ts', import.meta.url); const bridgeSource = fs.readFileSync(bridgePath, 'utf8'); +const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url); +const bridgeSourceFiles = fs + .readdirSync(bridgeDirPath, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith('.ts') && + !entry.name.includes('.test.'), + ) + .map((entry) => new URL(entry.name, bridgeDirPath)) + .sort((left, right) => left.pathname.localeCompare(right.pathname)); +const hostBridgeSource = bridgeSourceFiles + .map((file) => fs.readFileSync(file, 'utf8')) + .join('\n'); const mobileShellUrlPath = new URL('../src/shell/mobileShellUrl.ts', import.meta.url); const mobileShellUrlSource = fs.readFileSync(mobileShellUrlPath, 'utf8'); const mobileShellRuntimePath = new URL('../src/shell/mobileShellRuntime.ts', import.meta.url); @@ -193,7 +207,7 @@ function assertNoBlockedMobileChannelSnippets() { const sources = [ ['app.json', JSON.stringify(appConfig)], ['App.tsx', appSource], - ['mobileHostBridge.ts', bridgeSource], + ['src/host-bridge', hostBridgeSource], ['mobileShellUrl.ts', mobileShellUrlSource], ['mobileShellRuntime.ts', mobileShellRuntimeSource], ]; @@ -441,11 +455,11 @@ const sharedMethods = extractStringArrayExport( ); const handledMobileMethods = extractMobileBridgeHandledMethods(bridgeSource); const mobileCapabilities = extractStringArrayExport( - bridgeSource, + hostBridgeSource, 'MOBILE_HOST_CAPABILITIES', ); const iosMobileCapabilities = extractStringArrayExport( - bridgeSource, + hostBridgeSource, 'IOS_MOBILE_HOST_CAPABILITIES', ); const mobileCapabilitySet = new Set(mobileCapabilities); @@ -830,7 +844,7 @@ for (const forbiddenNotificationSnippet of [ 'addNotificationResponseReceivedListener', ...blockedScheduledNotificationSnippets, ]) { - if (bridgeSource.includes(forbiddenNotificationSnippet)) { + if (hostBridgeSource.includes(forbiddenNotificationSnippet)) { throw new Error( `mobile shell must not register remote or background notification flow: ${forbiddenNotificationSnippet}`, ); @@ -883,7 +897,7 @@ for (const snippet of [ 'resolveMobileHostBridgeResponse', 'rememberHostBridgeResponse', ]) { - if (!bridgeSource.includes(snippet)) { + if (!hostBridgeSource.includes(snippet)) { throw new Error(`mobile shell HostBridge missing ${snippet}`); } } diff --git a/apps/mobile-shell/src/host-bridge/mobileHostBridge.ts b/apps/mobile-shell/src/host-bridge/mobileHostBridge.ts index 21694fa73..e43f771c2 100644 --- a/apps/mobile-shell/src/host-bridge/mobileHostBridge.ts +++ b/apps/mobile-shell/src/host-bridge/mobileHostBridge.ts @@ -1,86 +1,65 @@ import * as Clipboard from 'expo-clipboard'; -import * as DocumentPicker from 'expo-document-picker'; -import { File, Paths } from 'expo-file-system'; import * as Haptics from 'expo-haptics'; -import * as ImagePicker from 'expo-image-picker'; import * as Linking from 'expo-linking'; import * as Notifications from 'expo-notifications'; -import * as Sharing from 'expo-sharing'; import { Appearance, Platform, PushNotificationIOS, - Share, } from 'react-native'; import { type ClipboardReadTextResult, type ClipboardWriteTextPayload, - type FileExportAudioPayload, - type FileExportAudioResult, - type FileExportImagePayload, - type FileExportImageResult, - type FileExportTextPayload, - type FileExportTextResult, - type FileImportAudioResult, - type FileImportImageResult, - type FileImportTextResult, type HapticsImpactPayload, - HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, - type HostBridgeAudioMimeType, - type HostBridgeCapability, type HostBridgeError, - type HostBridgeImageMimeType, - type HostBridgeMethod, type HostBridgeRequest, type HostBridgeResponse, - type HostBridgeTextMimeType, type NavigateNativePagePayload, - isHostBridgeMethod, normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, normalizeHostBridgeColorScheme, - normalizeHostBridgeExportFileName, normalizeHostBridgeExternalUrl, normalizeHostBridgeHapticsImpactStyle, normalizeHostBridgeLocalNotification, normalizeHostBridgeRequestId, type OpenExternalUrlPayload, type SetBadgeCountPayload, - type ShareOpenPayload, } from '../../../../packages/shared/src/contracts/hostBridge'; import { resolveMobileShellWebViewUrl } from '../shell/mobileShellNavigation'; import { getMobileNetworkStatus } from '../shell/mobileShellNetwork'; import { MOBILE_SHELL_HOST_VERSION } from '../shell/mobileShellRuntime'; +import { + captureImageFile, + exportAudioFile, + exportImageFile, + exportTextFile, + importAudioFile, + importImageFile, + importTextFile, +} from './mobileHostBridgeFiles'; +import { + HOST_BRIDGE_RESPONSE_CACHE_MAX, + type MobileHostBridgeNavigation, + failure, + invalidRequest, + isHostBridgeRequest, + normalizeMobileHostBridgeError, + ok, + parseRequest, + resolveMobileHostCapabilities, + unsupported, +} from './mobileHostBridgeProtocol'; +import { openShare } from './mobileHostBridgeShare'; + +export { + IOS_MOBILE_HOST_CAPABILITIES, + MOBILE_HOST_CAPABILITIES, + resolveMobileHostCapabilities, +} from './mobileHostBridgeProtocol'; -const WEB_APP_ORIGIN = 'https://app.genarrative.world'; -const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; -const EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024; -const EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; -const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; -const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; -const IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; -const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; -const HOST_BRIDGE_TEXT_MIME_TYPES = new Set([ - 'text/plain', - 'text/markdown', - 'text/csv', - 'application/json', -]); -const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/webp', -]); -const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set([ - 'audio/mpeg', - 'audio/mp4', - 'audio/wav', - 'audio/ogg', - 'audio/webm', -]); Notifications.setNotificationHandler({ handleNotification: async () => ({ @@ -91,49 +70,6 @@ Notifications.setNotificationHandler({ }), }); -export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ - 'host.getRuntime', - 'appearance.getColorScheme', - 'host.events', - 'app.lifecycle', - 'share.open', - 'share.setTarget', - 'navigation.openNativePage', - 'navigation.canGoBack', - 'app.reloadWebView', - 'app.openExternalUrl', - 'network.status', - 'network.statusChanged', - 'clipboard.writeText', - 'clipboard.readText', - 'file.exportText', - 'file.importText', - 'file.exportImage', - 'file.importImage', - 'file.captureImage', - 'file.importAudio', - 'file.exportAudio', - 'haptics.impact', - 'notification.showLocal', -]; - -export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ - ...MOBILE_HOST_CAPABILITIES, - 'app.setBadgeCount', -]; - -export function resolveMobileHostCapabilities(platform = Platform.OS) { - return platform === 'ios' - ? IOS_MOBILE_HOST_CAPABILITIES - : MOBILE_HOST_CAPABILITIES; -} - -export type MobileHostBridgeNavigation = { - allowedOrigin: string; - openWebViewUrl: (url: string) => void; - reloadWebView: () => void; -}; - let currentShareTarget: unknown = null; let navigation: MobileHostBridgeNavigation | null = null; const completedHostBridgeResponses = new Map(); @@ -148,108 +84,6 @@ export function configureMobileHostBridgeNavigation( navigation = nextNavigation; } -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 utf8ByteLength(value: string) { - let bytes = 0; - for (const character of value) { - const codePoint = character.codePointAt(0) ?? 0; - if (codePoint <= 0x7f) { - bytes += 1; - } else if (codePoint <= 0x7ff) { - bytes += 2; - } else if (codePoint <= 0xffff) { - bytes += 3; - } else { - bytes += 4; - } - } - return bytes; -} - -function normalizedBase64Data(value: unknown) { - if (typeof value !== 'string') { - return null; - } - - const normalizedValue = value.trim(); - if ( - !normalizedValue || - normalizedValue.length % 4 !== 0 || - !/^[A-Za-z0-9+/]+={0,2}$/u.test(normalizedValue) - ) { - return null; - } - - return normalizedValue; -} - -function base64DecodedByteLength(value: string) { - const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; - return Math.floor((value.length * 3) / 4) - padding; -} - -function isHostBridgeRequest(value: unknown): value is HostBridgeRequest { - if (!value || typeof value !== 'object') { - return false; - } - - const candidate = value as Partial; - const requestId = normalizeHostBridgeRequestId(candidate.id); - return ( - candidate.bridge === HOST_BRIDGE_PROTOCOL && - candidate.version === HOST_BRIDGE_VERSION && - requestId !== null && - isHostBridgeMethod(candidate.method) - ); -} - -function parseRequest(raw: string) { - try { - return JSON.parse(raw) as unknown; - } catch { - return null; - } -} - -function ok( - request: HostBridgeRequest, - result?: Result, -): HostBridgeResponse { - return { - bridge: HOST_BRIDGE_PROTOCOL, - version: HOST_BRIDGE_VERSION, - id: request.id, - ok: true, - result, - }; -} - -function failure( - request: Pick, - 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 = normalizeHostBridgeExternalUrl( (payload as OpenExternalUrlPayload | undefined)?.url, @@ -286,434 +120,6 @@ async function readClipboard(): Promise { return result; } -async function exportTextFile(payload: unknown): Promise { - const exportPayload = payload as FileExportTextPayload | undefined; - const content = exportPayload?.content; - if (typeof content !== 'string') { - throw invalidRequest('content is required'); - } - - const bytes = utf8ByteLength(content); - if (bytes > EXPORT_TEXT_MAX_BYTES) { - throw invalidRequest('content exceeds file export size limit'); - } - - const isSharingAvailable = await Sharing.isAvailableAsync(); - if (!isSharingAvailable) { - throw { - code: 'unsupported_capability', - message: 'file sharing is unavailable in mobile shell', - } satisfies HostBridgeError; - } - - const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName); - const mimeType = exportPayload?.mimeType || 'text/plain'; - const file = new File(Paths.cache, fileName); - file.write(content); - await Sharing.shareAsync(file.uri, { - mimeType, - UTI: 'public.plain-text', - dialogTitle: fileName, - }); - - return { - action: 'saved', - fileName, - bytes, - }; -} - -function normalizeImportedTextMimeType( - value: unknown, - fileName: string, -): HostBridgeTextMimeType | null { - if (typeof value === 'string') { - const mimeType = value.toLowerCase(); - if (HOST_BRIDGE_TEXT_MIME_TYPES.has(mimeType as HostBridgeTextMimeType)) { - return mimeType as HostBridgeTextMimeType; - } - } - - const normalizedName = fileName.toLowerCase(); - if (normalizedName.endsWith('.json')) { - return 'application/json'; - } - if (normalizedName.endsWith('.md') || normalizedName.endsWith('.markdown')) { - return 'text/markdown'; - } - if (normalizedName.endsWith('.csv')) { - return 'text/csv'; - } - if (normalizedName.endsWith('.txt')) { - return 'text/plain'; - } - - return null; -} - -async function importTextFile(): Promise { - const result = await DocumentPicker.getDocumentAsync({ - copyToCacheDirectory: true, - multiple: false, - type: ['text/*', 'application/json'], - }); - if (result.canceled) { - throw { - code: 'cancelled', - message: 'file import cancelled', - } satisfies HostBridgeError; - } - - const asset = result.assets[0]; - if (!asset?.uri) { - throw invalidRequest('text file is required'); - } - - const fileName = normalizeHostBridgeExportFileName( - asset.name || 'genarrative-import.txt', - ); - const mimeType = normalizeImportedTextMimeType(asset.mimeType, fileName); - if (!mimeType) { - throw invalidRequest('mimeType must be an allowed text type'); - } - if ( - typeof asset.size === 'number' && - (asset.size <= 0 || asset.size > IMPORT_TEXT_MAX_BYTES) - ) { - throw invalidRequest('text exceeds file import size limit'); - } - - const file = new File(asset.uri); - const content = await file.text(); - const bytes = utf8ByteLength(content); - if (bytes <= 0 || bytes > IMPORT_TEXT_MAX_BYTES) { - throw invalidRequest('text exceeds file import size limit'); - } - - return { - action: 'selected', - fileName, - content, - mimeType, - bytes, - }; -} - -async function exportImageFile(payload: unknown): Promise { - const exportPayload = payload as FileExportImagePayload | undefined; - const mimeType = exportPayload?.mimeType; - if ( - typeof mimeType !== 'string' || - !HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType) - ) { - throw invalidRequest('mimeType must be an allowed image type'); - } - - const base64Data = normalizedBase64Data(exportPayload?.base64Data); - if (!base64Data) { - throw invalidRequest('base64Data is required'); - } - const bytes = base64DecodedByteLength(base64Data); - if (bytes > EXPORT_IMAGE_MAX_BYTES) { - throw invalidRequest('image exceeds file export size limit'); - } - - const isSharingAvailable = await Sharing.isAvailableAsync(); - if (!isSharingAvailable) { - throw { - code: 'unsupported_capability', - message: 'file sharing is unavailable in mobile shell', - } satisfies HostBridgeError; - } - - const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName); - const file = new File(Paths.cache, fileName); - file.write(base64Data, { encoding: 'base64' }); - await Sharing.shareAsync(file.uri, { - mimeType, - UTI: mimeType === 'image/png' ? 'public.png' : 'public.image', - dialogTitle: fileName, - }); - - return { - action: 'saved', - fileName, - bytes, - }; -} - -function normalizeImportedImageMimeType( - value: unknown, -): HostBridgeImageMimeType | null { - if (typeof value !== 'string') { - return null; - } - - const mimeType = value.toLowerCase(); - return HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType) - ? (mimeType as HostBridgeImageMimeType) - : null; -} - -function fallbackImportedImageFileName(mimeType: HostBridgeImageMimeType) { - if (mimeType === 'image/jpeg') { - return 'genarrative-import.jpg'; - } - if (mimeType === 'image/webp') { - return 'genarrative-import.webp'; - } - return 'genarrative-import.png'; -} - -function audioFileExtension(mimeType: HostBridgeAudioMimeType) { - if (mimeType === 'audio/mpeg') { - return 'mp3'; - } - if (mimeType === 'audio/mp4') { - return 'm4a'; - } - if (mimeType === 'audio/wav') { - return 'wav'; - } - if (mimeType === 'audio/ogg') { - return 'ogg'; - } - return 'webm'; -} - -function normalizeExportedAudioFileName( - rawFileName: unknown, - mimeType: HostBridgeAudioMimeType, -) { - const fileName = normalizeHostBridgeExportFileName(rawFileName); - const extension = audioFileExtension(mimeType); - return fileName.toLowerCase().endsWith(`.${extension}`) - ? fileName - : `${fileName}.${extension}`; -} - -function normalizeImportedAudioMimeType( - value: unknown, - fileName: string, -): HostBridgeAudioMimeType | null { - if (typeof value === 'string') { - const mimeType = value.toLowerCase(); - if (HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType)) { - return mimeType as HostBridgeAudioMimeType; - } - } - - const normalizedName = fileName.toLowerCase(); - if (normalizedName.endsWith('.mp3')) { - return 'audio/mpeg'; - } - if (normalizedName.endsWith('.m4a') || normalizedName.endsWith('.mp4')) { - return 'audio/mp4'; - } - if (normalizedName.endsWith('.wav')) { - return 'audio/wav'; - } - if (normalizedName.endsWith('.ogg')) { - return 'audio/ogg'; - } - if (normalizedName.endsWith('.webm')) { - return 'audio/webm'; - } - - return null; -} - -async function exportAudioFile( - payload: unknown, -): Promise { - const exportPayload = payload as FileExportAudioPayload | undefined; - const mimeType = exportPayload?.mimeType; - if ( - typeof mimeType !== 'string' || - !HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType) - ) { - throw invalidRequest('mimeType must be an allowed audio type'); - } - - const base64Data = normalizedBase64Data(exportPayload?.base64Data); - if (!base64Data) { - throw invalidRequest('base64Data is required'); - } - const bytes = base64DecodedByteLength(base64Data); - if (bytes <= 0 || bytes > EXPORT_AUDIO_MAX_BYTES) { - throw invalidRequest('audio exceeds file export size limit'); - } - - const isSharingAvailable = await Sharing.isAvailableAsync(); - if (!isSharingAvailable) { - throw { - code: 'unsupported_capability', - message: 'file sharing is unavailable in mobile shell', - } satisfies HostBridgeError; - } - - const fileName = normalizeExportedAudioFileName( - exportPayload?.fileName, - mimeType as HostBridgeAudioMimeType, - ); - const file = new File(Paths.cache, fileName); - file.write(base64Data, { encoding: 'base64' }); - await Sharing.shareAsync(file.uri, { - mimeType, - UTI: 'public.audio', - dialogTitle: fileName, - }); - - return { - action: 'saved', - fileName, - bytes, - }; -} - -function imagePickerResultToImportPayload( - result: ImagePicker.ImagePickerResult, - action: FileImportImageResult['action'], -): FileImportImageResult { - if (result.canceled) { - throw { - code: 'cancelled', - message: 'file import cancelled', - } satisfies HostBridgeError; - } - - const asset = result.assets[0]; - if (!asset || asset.type !== 'image') { - throw invalidRequest('image asset is required'); - } - - const mimeType = normalizeImportedImageMimeType(asset.mimeType); - if (!mimeType) { - throw invalidRequest('mimeType must be an allowed image type'); - } - - const base64Data = normalizedBase64Data(asset.base64); - if (!base64Data) { - throw invalidRequest('base64Data is required'); - } - - const bytes = base64DecodedByteLength(base64Data); - if (bytes <= 0 || bytes > IMPORT_IMAGE_MAX_BYTES) { - throw invalidRequest('image exceeds file import size limit'); - } - if ( - typeof asset.fileSize === 'number' && - asset.fileSize > IMPORT_IMAGE_MAX_BYTES - ) { - throw invalidRequest('image exceeds file import size limit'); - } - - return { - action, - fileName: normalizeHostBridgeExportFileName( - asset.fileName || fallbackImportedImageFileName(mimeType), - ), - base64Data, - mimeType, - bytes, - }; -} - -async function importImageFile(): Promise { - const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { - throw { - code: 'host_error', - message: 'photo library permission denied', - } satisfies HostBridgeError; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - allowsEditing: false, - allowsMultipleSelection: false, - base64: true, - exif: false, - mediaTypes: ['images'], - quality: 1, - }); - - return imagePickerResultToImportPayload(result, 'selected'); -} - -async function captureImageFile(): Promise { - const permission = await ImagePicker.requestCameraPermissionsAsync(); - if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { - throw { - code: 'host_error', - message: 'camera permission denied', - } satisfies HostBridgeError; - } - - const result = await ImagePicker.launchCameraAsync({ - allowsEditing: false, - base64: true, - exif: false, - mediaTypes: ['images'], - quality: 1, - }); - - return imagePickerResultToImportPayload(result, 'captured'); -} - -async function importAudioFile(): Promise { - const result = await DocumentPicker.getDocumentAsync({ - copyToCacheDirectory: true, - multiple: false, - type: [ - 'audio/mpeg', - 'audio/mp4', - 'audio/wav', - 'audio/ogg', - 'audio/webm', - ], - }); - if (result.canceled) { - throw { - code: 'cancelled', - message: 'file import cancelled', - } satisfies HostBridgeError; - } - - const asset = result.assets[0]; - if (!asset?.uri) { - throw invalidRequest('audio file is required'); - } - - const fileName = normalizeHostBridgeExportFileName( - asset.name || 'genarrative-import-audio.webm', - ); - const mimeType = normalizeImportedAudioMimeType(asset.mimeType, fileName); - if (!mimeType) { - throw invalidRequest('mimeType must be an allowed audio type'); - } - if ( - typeof asset.size === 'number' && - (asset.size <= 0 || asset.size > IMPORT_AUDIO_MAX_BYTES) - ) { - throw invalidRequest('audio exceeds file import size limit'); - } - - const file = new File(asset.uri); - const base64Data = await file.base64(); - const bytes = base64DecodedByteLength(base64Data); - if (bytes <= 0 || bytes > IMPORT_AUDIO_MAX_BYTES) { - throw invalidRequest('audio exceeds file import size limit'); - } - - return { - action: 'selected', - fileName, - base64Data, - mimeType, - bytes, - }; -} - async function runHaptics(payload: unknown) { const style = normalizeHostBridgeHapticsImpactStyle( (payload as HapticsImpactPayload | undefined)?.style, @@ -815,84 +221,6 @@ function getColorScheme() { }; } -function stringField(value: unknown, field: string) { - if (!value || typeof value !== 'object') { - return undefined; - } - - const fieldValue = (value as Record)[field]; - if (typeof fieldValue !== 'string') { - return undefined; - } - - const text = fieldValue.trim(); - return text || undefined; -} - -function shareTargetPayload(value: unknown) { - if (!value || typeof value !== 'object') { - return value; - } - - const target = value as Record; - return target.target ?? value; -} - -function workDetailUrl(work: string) { - return `${WEB_APP_ORIGIN}/works/detail?work=${encodeURIComponent(work)}`; -} - -function webAppPathUrl(path: string) { - return new URL(path, WEB_APP_ORIGIN).toString(); -} - -function normalizeSharePayload(value: unknown): ShareOpenPayload | null { - const target = shareTargetPayload(value); - const payload = - target && typeof target === 'object' - ? (target as Record).payload ?? target - : target; - - if (!payload || typeof payload !== 'object') { - return null; - } - - const title = stringField(payload, 'title'); - const message = stringField(payload, 'message'); - const directUrl = stringField(payload, 'url') ?? stringField(payload, 'href'); - const work = stringField(payload, 'work'); - const path = stringField(payload, 'path') ?? stringField(payload, 'targetPath'); - const url = directUrl ?? (work ? workDetailUrl(work) : undefined) ?? (path ? webAppPathUrl(path) : undefined); - - if (!title && !message && !url) { - return null; - } - - return { - ...(title ? { title } : {}), - ...(message ? { message } : {}), - ...(url ? { url } : {}), - }; -} - -async function openShare(payload: unknown) { - const sharePayload = - normalizeSharePayload(payload) ?? normalizeSharePayload(currentShareTarget); - if (!sharePayload) { - throw invalidRequest('share target is required'); - } - - 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; -} - function openNativePage(payload: unknown) { if (!navigation) { throw unsupported('navigation.openNativePage'); @@ -967,7 +295,7 @@ async function handleRequest(request: HostBridgeRequest) { case 'app.setBadgeCount': return ok(request, setBadgeCount(request.payload)); case 'share.open': - return ok(request, await openShare(request.payload)); + return ok(request, await openShare(request.payload, currentShareTarget)); case 'share.setTarget': currentShareTarget = request.payload && typeof request.payload === 'object' @@ -984,22 +312,6 @@ async function handleRequest(request: HostBridgeRequest) { } } -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), - }; -} - function rememberHostBridgeResponse(response: HostBridgeResponse) { completedHostBridgeResponses.set(response.id, response); if (completedHostBridgeResponses.size > HOST_BRIDGE_RESPONSE_CACHE_MAX) { @@ -1024,7 +336,9 @@ async function resolveMobileHostBridgeResponse(request: HostBridgeRequest) { } const responsePromise = handleRequest(request) - .catch((error: unknown) => failure(request, normalizeError(error))) + .catch((error: unknown) => + failure(request, normalizeMobileHostBridgeError(error)), + ) .then(rememberHostBridgeResponse); inFlightHostBridgeResponses.set(request.id, responsePromise); diff --git a/apps/mobile-shell/src/host-bridge/mobileHostBridgeFiles.ts b/apps/mobile-shell/src/host-bridge/mobileHostBridgeFiles.ts new file mode 100644 index 000000000..d9237b6a7 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/mobileHostBridgeFiles.ts @@ -0,0 +1,518 @@ +import * as DocumentPicker from 'expo-document-picker'; +import { File, Paths } from 'expo-file-system'; +import * as ImagePicker from 'expo-image-picker'; +import * as Sharing from 'expo-sharing'; + +import { + type FileExportAudioPayload, + type FileExportAudioResult, + type FileExportImagePayload, + type FileExportImageResult, + type FileExportTextPayload, + type FileExportTextResult, + type FileImportAudioResult, + type FileImportImageResult, + type FileImportTextResult, + type HostBridgeAudioMimeType, + type HostBridgeError, + type HostBridgeImageMimeType, + type HostBridgeTextMimeType, + normalizeHostBridgeExportFileName, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { invalidRequest } from './mobileHostBridgeProtocol'; + +const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; +const EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024; +const EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; +const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024; +const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; +const IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024; +const HOST_BRIDGE_TEXT_MIME_TYPES = new Set([ + 'text/plain', + 'text/markdown', + 'text/csv', + 'application/json', +]); +const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', +]); +const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set([ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', +]); + +export function utf8ByteLength(value: string) { + let bytes = 0; + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint <= 0x7f) { + bytes += 1; + } else if (codePoint <= 0x7ff) { + bytes += 2; + } else if (codePoint <= 0xffff) { + bytes += 3; + } else { + bytes += 4; + } + } + return bytes; +} + +function normalizedBase64Data(value: unknown) { + if (typeof value !== 'string') { + return null; + } + + const normalizedValue = value.trim(); + if ( + !normalizedValue || + normalizedValue.length % 4 !== 0 || + !/^[A-Za-z0-9+/]+={0,2}$/u.test(normalizedValue) + ) { + return null; + } + + return normalizedValue; +} + +function base64DecodedByteLength(value: string) { + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + return Math.floor((value.length * 3) / 4) - padding; +} + +export async function exportTextFile( + payload: unknown, +): Promise { + const exportPayload = payload as FileExportTextPayload | undefined; + const content = exportPayload?.content; + if (typeof content !== 'string') { + throw invalidRequest('content is required'); + } + + const bytes = utf8ByteLength(content); + if (bytes > EXPORT_TEXT_MAX_BYTES) { + throw invalidRequest('content exceeds file export size limit'); + } + + const isSharingAvailable = await Sharing.isAvailableAsync(); + if (!isSharingAvailable) { + throw { + code: 'unsupported_capability', + message: 'file sharing is unavailable in mobile shell', + } satisfies HostBridgeError; + } + + const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName); + const mimeType = exportPayload?.mimeType || 'text/plain'; + const file = new File(Paths.cache, fileName); + file.write(content); + await Sharing.shareAsync(file.uri, { + mimeType, + UTI: 'public.plain-text', + dialogTitle: fileName, + }); + + return { + action: 'saved', + fileName, + bytes, + }; +} + +function normalizeImportedTextMimeType( + value: unknown, + fileName: string, +): HostBridgeTextMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if (HOST_BRIDGE_TEXT_MIME_TYPES.has(mimeType as HostBridgeTextMimeType)) { + return mimeType as HostBridgeTextMimeType; + } + } + + const normalizedName = fileName.toLowerCase(); + if (normalizedName.endsWith('.json')) { + return 'application/json'; + } + if (normalizedName.endsWith('.md') || normalizedName.endsWith('.markdown')) { + return 'text/markdown'; + } + if (normalizedName.endsWith('.csv')) { + return 'text/csv'; + } + if (normalizedName.endsWith('.txt')) { + return 'text/plain'; + } + + return null; +} + +export async function importTextFile(): Promise { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: false, + type: ['text/*', 'application/json'], + }); + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + } satisfies HostBridgeError; + } + + const asset = result.assets[0]; + if (!asset?.uri) { + throw invalidRequest('text file is required'); + } + + const fileName = normalizeHostBridgeExportFileName( + asset.name || 'genarrative-import.txt', + ); + const mimeType = normalizeImportedTextMimeType(asset.mimeType, fileName); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed text type'); + } + if ( + typeof asset.size === 'number' && + (asset.size <= 0 || asset.size > IMPORT_TEXT_MAX_BYTES) + ) { + throw invalidRequest('text exceeds file import size limit'); + } + + const file = new File(asset.uri); + const content = await file.text(); + const bytes = utf8ByteLength(content); + if (bytes <= 0 || bytes > IMPORT_TEXT_MAX_BYTES) { + throw invalidRequest('text exceeds file import size limit'); + } + + return { + action: 'selected', + fileName, + content, + mimeType, + bytes, + }; +} + +export async function exportImageFile( + payload: unknown, +): Promise { + const exportPayload = payload as FileExportImagePayload | undefined; + const mimeType = exportPayload?.mimeType; + if ( + typeof mimeType !== 'string' || + !HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType) + ) { + throw invalidRequest('mimeType must be an allowed image type'); + } + + const base64Data = normalizedBase64Data(exportPayload?.base64Data); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + const bytes = base64DecodedByteLength(base64Data); + if (bytes > EXPORT_IMAGE_MAX_BYTES) { + throw invalidRequest('image exceeds file export size limit'); + } + + const isSharingAvailable = await Sharing.isAvailableAsync(); + if (!isSharingAvailable) { + throw { + code: 'unsupported_capability', + message: 'file sharing is unavailable in mobile shell', + } satisfies HostBridgeError; + } + + const fileName = normalizeHostBridgeExportFileName(exportPayload?.fileName); + const file = new File(Paths.cache, fileName); + file.write(base64Data, { encoding: 'base64' }); + await Sharing.shareAsync(file.uri, { + mimeType, + UTI: mimeType === 'image/png' ? 'public.png' : 'public.image', + dialogTitle: fileName, + }); + + return { + action: 'saved', + fileName, + bytes, + }; +} + +function normalizeImportedImageMimeType( + value: unknown, +): HostBridgeImageMimeType | null { + if (typeof value !== 'string') { + return null; + } + + const mimeType = value.toLowerCase(); + return HOST_BRIDGE_IMAGE_MIME_TYPES.has(mimeType as HostBridgeImageMimeType) + ? (mimeType as HostBridgeImageMimeType) + : null; +} + +function fallbackImportedImageFileName(mimeType: HostBridgeImageMimeType) { + if (mimeType === 'image/jpeg') { + return 'genarrative-import.jpg'; + } + if (mimeType === 'image/webp') { + return 'genarrative-import.webp'; + } + return 'genarrative-import.png'; +} + +function imagePickerResultToImportPayload( + result: ImagePicker.ImagePickerResult, + action: FileImportImageResult['action'], +): FileImportImageResult { + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + } satisfies HostBridgeError; + } + + const asset = result.assets[0]; + if (!asset || asset.type !== 'image') { + throw invalidRequest('image asset is required'); + } + + const mimeType = normalizeImportedImageMimeType(asset.mimeType); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed image type'); + } + + const base64Data = normalizedBase64Data(asset.base64); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > IMPORT_IMAGE_MAX_BYTES) { + throw invalidRequest('image exceeds file import size limit'); + } + if ( + typeof asset.fileSize === 'number' && + asset.fileSize > IMPORT_IMAGE_MAX_BYTES + ) { + throw invalidRequest('image exceeds file import size limit'); + } + + return { + action, + fileName: normalizeHostBridgeExportFileName( + asset.fileName || fallbackImportedImageFileName(mimeType), + ), + base64Data, + mimeType, + bytes, + }; +} + +export async function importImageFile(): Promise { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { + throw { + code: 'host_error', + message: 'photo library permission denied', + } satisfies HostBridgeError; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: false, + allowsMultipleSelection: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + + return imagePickerResultToImportPayload(result, 'selected'); +} + +export async function captureImageFile(): Promise { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { + throw { + code: 'host_error', + message: 'camera permission denied', + } satisfies HostBridgeError; + } + + const result = await ImagePicker.launchCameraAsync({ + allowsEditing: false, + base64: true, + exif: false, + mediaTypes: ['images'], + quality: 1, + }); + + return imagePickerResultToImportPayload(result, 'captured'); +} + +function audioFileExtension(mimeType: HostBridgeAudioMimeType) { + if (mimeType === 'audio/mpeg') { + return 'mp3'; + } + if (mimeType === 'audio/mp4') { + return 'm4a'; + } + if (mimeType === 'audio/wav') { + return 'wav'; + } + if (mimeType === 'audio/ogg') { + return 'ogg'; + } + return 'webm'; +} + +function normalizeExportedAudioFileName( + rawFileName: unknown, + mimeType: HostBridgeAudioMimeType, +) { + const fileName = normalizeHostBridgeExportFileName(rawFileName); + const extension = audioFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + +function normalizeImportedAudioMimeType( + value: unknown, + fileName: string, +): HostBridgeAudioMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if (HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType)) { + return mimeType as HostBridgeAudioMimeType; + } + } + + const normalizedName = fileName.toLowerCase(); + if (normalizedName.endsWith('.mp3')) { + return 'audio/mpeg'; + } + if (normalizedName.endsWith('.m4a') || normalizedName.endsWith('.mp4')) { + return 'audio/mp4'; + } + if (normalizedName.endsWith('.wav')) { + return 'audio/wav'; + } + if (normalizedName.endsWith('.ogg')) { + return 'audio/ogg'; + } + if (normalizedName.endsWith('.webm')) { + return 'audio/webm'; + } + + return null; +} + +export async function exportAudioFile( + payload: unknown, +): Promise { + const exportPayload = payload as FileExportAudioPayload | undefined; + const mimeType = exportPayload?.mimeType; + if ( + typeof mimeType !== 'string' || + !HOST_BRIDGE_AUDIO_MIME_TYPES.has(mimeType as HostBridgeAudioMimeType) + ) { + throw invalidRequest('mimeType must be an allowed audio type'); + } + + const base64Data = normalizedBase64Data(exportPayload?.base64Data); + if (!base64Data) { + throw invalidRequest('base64Data is required'); + } + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > EXPORT_AUDIO_MAX_BYTES) { + throw invalidRequest('audio exceeds file export size limit'); + } + + const isSharingAvailable = await Sharing.isAvailableAsync(); + if (!isSharingAvailable) { + throw { + code: 'unsupported_capability', + message: 'file sharing is unavailable in mobile shell', + } satisfies HostBridgeError; + } + + const fileName = normalizeExportedAudioFileName( + exportPayload?.fileName, + mimeType as HostBridgeAudioMimeType, + ); + const file = new File(Paths.cache, fileName); + file.write(base64Data, { encoding: 'base64' }); + await Sharing.shareAsync(file.uri, { + mimeType, + UTI: 'public.audio', + dialogTitle: fileName, + }); + + return { + action: 'saved', + fileName, + bytes, + }; +} + +export async function importAudioFile(): Promise { + const result = await DocumentPicker.getDocumentAsync({ + copyToCacheDirectory: true, + multiple: false, + type: [ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', + 'audio/webm', + ], + }); + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + } satisfies HostBridgeError; + } + + const asset = result.assets[0]; + if (!asset?.uri) { + throw invalidRequest('audio file is required'); + } + + const fileName = normalizeHostBridgeExportFileName( + asset.name || 'genarrative-import-audio.webm', + ); + const mimeType = normalizeImportedAudioMimeType(asset.mimeType, fileName); + if (!mimeType) { + throw invalidRequest('mimeType must be an allowed audio type'); + } + if ( + typeof asset.size === 'number' && + (asset.size <= 0 || asset.size > IMPORT_AUDIO_MAX_BYTES) + ) { + throw invalidRequest('audio exceeds file import size limit'); + } + + const file = new File(asset.uri); + const base64Data = await file.base64(); + const bytes = base64DecodedByteLength(base64Data); + if (bytes <= 0 || bytes > IMPORT_AUDIO_MAX_BYTES) { + throw invalidRequest('audio exceeds file import size limit'); + } + + return { + action: 'selected', + fileName, + base64Data, + mimeType, + bytes, + }; +} diff --git a/apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts b/apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts new file mode 100644 index 000000000..cb95f80a2 --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts @@ -0,0 +1,137 @@ +import { Platform } from 'react-native'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeCapability, + type HostBridgeError, + type HostBridgeMethod, + type HostBridgeRequest, + type HostBridgeResponse, + isHostBridgeMethod, + normalizeHostBridgeRequestId, +} from '../../../../packages/shared/src/contracts/hostBridge'; + +export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; + +export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ + 'host.getRuntime', + 'appearance.getColorScheme', + 'host.events', + 'app.lifecycle', + 'share.open', + 'share.setTarget', + 'navigation.openNativePage', + 'navigation.canGoBack', + 'app.reloadWebView', + 'app.openExternalUrl', + 'network.status', + 'network.statusChanged', + 'clipboard.writeText', + 'clipboard.readText', + 'file.exportText', + 'file.importText', + 'file.exportImage', + 'file.importImage', + 'file.captureImage', + 'file.importAudio', + 'file.exportAudio', + 'haptics.impact', + 'notification.showLocal', +]; + +export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ + ...MOBILE_HOST_CAPABILITIES, + 'app.setBadgeCount', +]; + +export function resolveMobileHostCapabilities(platform = Platform.OS) { + return platform === 'ios' + ? IOS_MOBILE_HOST_CAPABILITIES + : MOBILE_HOST_CAPABILITIES; +} + +export type MobileHostBridgeNavigation = { + allowedOrigin: string; + openWebViewUrl: (url: string) => void; + reloadWebView: () => void; +}; + +export function unsupported(method: HostBridgeMethod): HostBridgeError { + return { + code: 'unsupported_method', + message: `${method} unsupported in mobile shell`, + }; +} + +export function invalidRequest(message: string): HostBridgeError { + return { + code: 'invalid_request', + message, + }; +} + +export function isHostBridgeRequest(value: unknown): value is HostBridgeRequest { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + const requestId = normalizeHostBridgeRequestId(candidate.id); + return ( + candidate.bridge === HOST_BRIDGE_PROTOCOL && + candidate.version === HOST_BRIDGE_VERSION && + requestId !== null && + isHostBridgeMethod(candidate.method) + ); +} + +export function parseRequest(raw: string) { + try { + return JSON.parse(raw) as unknown; + } catch { + return null; + } +} + +export function ok( + request: HostBridgeRequest, + result?: Result, +): HostBridgeResponse { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: request.id, + ok: true, + result, + }; +} + +export function failure( + request: Pick, + error: HostBridgeError, +): HostBridgeResponse { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: request.id, + ok: false, + error, + }; +} + +export function normalizeMobileHostBridgeError(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), + }; +} diff --git a/apps/mobile-shell/src/host-bridge/mobileHostBridgeShare.ts b/apps/mobile-shell/src/host-bridge/mobileHostBridgeShare.ts new file mode 100644 index 000000000..109a35a8d --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/mobileHostBridgeShare.ts @@ -0,0 +1,87 @@ +import { Share } from 'react-native'; + +import { type ShareOpenPayload } from '../../../../packages/shared/src/contracts/hostBridge'; +import { invalidRequest } from './mobileHostBridgeProtocol'; + +const WEB_APP_ORIGIN = 'https://app.genarrative.world'; + +function stringField(value: unknown, field: string) { + if (!value || typeof value !== 'object') { + return undefined; + } + + const fieldValue = (value as Record)[field]; + if (typeof fieldValue !== 'string') { + return undefined; + } + + const text = fieldValue.trim(); + return text || undefined; +} + +function shareTargetPayload(value: unknown) { + if (!value || typeof value !== 'object') { + return value; + } + + const target = value as Record; + return target.target ?? value; +} + +function workDetailUrl(work: string) { + return `${WEB_APP_ORIGIN}/works/detail?work=${encodeURIComponent(work)}`; +} + +function webAppPathUrl(path: string) { + return new URL(path, WEB_APP_ORIGIN).toString(); +} + +function normalizeSharePayload(value: unknown): ShareOpenPayload | null { + const target = shareTargetPayload(value); + const payload = + target && typeof target === 'object' + ? (target as Record).payload ?? target + : target; + + if (!payload || typeof payload !== 'object') { + return null; + } + + const title = stringField(payload, 'title'); + const message = stringField(payload, 'message'); + const directUrl = stringField(payload, 'url') ?? stringField(payload, 'href'); + const work = stringField(payload, 'work'); + const path = stringField(payload, 'path') ?? stringField(payload, 'targetPath'); + const url = + directUrl ?? + (work ? workDetailUrl(work) : undefined) ?? + (path ? webAppPathUrl(path) : undefined); + + if (!title && !message && !url) { + return null; + } + + return { + ...(title ? { title } : {}), + ...(message ? { message } : {}), + ...(url ? { url } : {}), + }; +} + +export async function openShare(payload: unknown, currentShareTarget: unknown) { + const sharePayload = + normalizeSharePayload(payload) ?? normalizeSharePayload(currentShareTarget); + if (!sharePayload) { + throw invalidRequest('share target is required'); + } + + 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; +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6c35b11d6..534aa493b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -86,7 +86,7 @@ - 2026-06-18 移动壳 WebView 安全开关:Expo 移动壳 WebView 必须显式禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;同源主站页面才能留在带 HostBridge 的 WebView 内,外链只通过受控协议离开容器交给系统。配置检查和移动壳导航测试会拒绝这些边界被放宽。 - 2026-06-18 移动壳 WebView 默认下载边界:Expo WebView 内网页自动下载和 `` 直接落盘默认关闭;壳层注入脚本阻断 download 链接,iOS `onFileDownload` 只丢弃不落盘,Android 包配置通过 `blockedPermissions` 移除外部存储读写、管理外部存储和请求安装包权限。移动端文本、图片、音频保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等 HostBridge 受控导出能力进入系统分享 / 保存面板。 - 2026-06-18 移动壳 HostBridge 消息来源校验:Expo 移动壳 `onMessage` 必须根据 `event.nativeEvent.url` 校验消息来源,只有同源主站页面能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域、协议降级和危险协议页面消息直接丢弃,不返回宿主能力错误细节。该规则与 WebView 导航留壳规则共用同源判断,配置检查和移动壳导航测试会拒绝移除。 -- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。Tauri `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 +- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。Expo `mobileHostBridgeProtocol.ts`、`mobileHostBridgeFiles.ts`、`mobileHostBridgeShare.ts` 和 facade `mobileHostBridge.ts` 分别对齐 Tauri `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs`。Tauri `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 @@ -2475,6 +2475,6 @@ ## 2026-06-18 三端宿主桥接层文件结构对齐 - 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。 -- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 负责协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 负责 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 deep link、tray、webview 分文件承接容器行为,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单。 +- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts`、`mobileHostBridgeFiles.ts`、`mobileHostBridgeShare.ts` 和 facade `mobileHostBridge.ts`,分别负责协议 / 能力清单 / request 校验 / replay 基础、文件能力、分享能力和 method 分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 负责 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 deep link、tray、webview 分文件承接容器行为,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单。 - 影响范围:`miniprogram/host-bridge/`、`miniprogram/pages/*/index.js`、`apps/mobile-shell/src/`、`apps/desktop-shell/src-tauri/src/`、`scripts/check-native-shells.mjs`、宿主壳方案文档。 - 验证方式:`npm run test -- miniprogram/host-bridge/wechatHostBridgeWebView.test.js miniprogram/host-bridge/wechatHostBridgePayment.test.js miniprogram/host-bridge/wechatHostBridgeShareGrid.test.js miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 836185f53..88155018d 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -64,7 +64,7 @@ src/ 已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。 -三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/wechatShell*.js`,页面入口只做 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。 +三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/wechatShell*.js`,页面入口只做 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`mobileHostBridgeFiles.ts` / `mobileHostBridgeShare.ts` 分别承接文件和分享能力,`mobileHostBridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。 ## HostBridge 消息协议 @@ -415,7 +415,7 @@ GameBridge 禁止: 2026-06-18 追加:移动壳 HostBridge 消息入口增加来源校验。`onMessage` 不只依赖导航拦截和 `originWhitelist`,还会读取 `event.nativeEvent.url`,只有同源主站页面才能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域 URL、协议降级或危险协议页面发来的消息全部丢弃,不返回 HostBridge 错误细节。该校验与 `navigation.openNativePage` 共用同源规则,防止历史中间页或异常页面在带完整 HostBridge 的 WebView 中发起宿主能力请求。 -2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 和 `apps/mobile-shell/src/shell/mobileShell*.ts`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面或桌面入口。 +2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts`、`mobileHostBridgeFiles.ts`、`mobileHostBridgeShare.ts` 和 facade `mobileHostBridge.ts`,与桌面端 `host_bridge/protocol.rs`、`files.rs`、`share.rs`、`mod.rs` 对齐;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面或桌面入口。 ### Phase 4:宿主能力扩展 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index b1172d9d1..fecd4ae84 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -37,7 +37,7 @@ AI H5 sandbox -> parent HostBridge adapter ``` -桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 +桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridgeProtocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础,`mobileHostBridgeFiles.ts` / `mobileHostBridgeShare.ts` 分别承接文件和分享能力,`mobileHostBridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`files.rs`、`share.rs` 和 `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。 ## 首批能力 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 98a55fe66..625f51447 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -30,6 +30,9 @@ const expectedWechatShellFiles = [ const expectedMobileHostBridgeFiles = [ 'mobileHostBridge.test.ts', 'mobileHostBridge.ts', + 'mobileHostBridgeFiles.ts', + 'mobileHostBridgeProtocol.ts', + 'mobileHostBridgeShare.ts', ]; const expectedMobileShellFiles = [ 'mobileShellDeepLink.test.ts',