From cde936bd6d84d951acd2fc9ad236052a05529237 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 07:11:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=BD=BD=E8=8D=B7=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳文件执行与载荷校验拆成 files 和 filePayloads 移动壳单端门禁登记文件载荷模块 宿主壳方案和统一协议同步移动文件桥接分层 --- apps/mobile-shell/scripts/check-config.mjs | 6 + .../src/host-bridge/filePayloads.ts | 458 +++++++++++++++++ apps/mobile-shell/src/host-bridge/files.ts | 477 ++---------------- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 8 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 6 +- scripts/check-native-shells.mjs | 1 + 7 files changed, 503 insertions(+), 454 deletions(-) create mode 100644 apps/mobile-shell/src/host-bridge/filePayloads.ts diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 771db3f5f..c21e9fcf3 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -26,6 +26,11 @@ const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url); const dispatchSource = fs.readFileSync(dispatchPath, 'utf8'); const filesPath = new URL('../src/host-bridge/files.ts', import.meta.url); const filesSource = fs.readFileSync(filesPath, 'utf8'); +const filePayloadsPath = new URL( + '../src/host-bridge/filePayloads.ts', + import.meta.url, +); +const filePayloadsSource = fs.readFileSync(filePayloadsPath, 'utf8'); const hapticsPath = new URL('../src/host-bridge/haptics.ts', import.meta.url); const hapticsSource = fs.readFileSync(hapticsPath, 'utf8'); const hostBridgeNavigationPath = new URL( @@ -114,6 +119,7 @@ const requiredMobileShellSourceModules = [ 'host-bridge/capabilities.ts', 'host-bridge/clipboard.ts', 'host-bridge/dispatch.ts', + 'host-bridge/filePayloads.ts', 'host-bridge/files.ts', 'host-bridge/haptics.ts', 'host-bridge/navigation.ts', diff --git a/apps/mobile-shell/src/host-bridge/filePayloads.ts b/apps/mobile-shell/src/host-bridge/filePayloads.ts new file mode 100644 index 000000000..8e076ec1b --- /dev/null +++ b/apps/mobile-shell/src/host-bridge/filePayloads.ts @@ -0,0 +1,458 @@ +import type * as ImagePicker from 'expo-image-picker'; + +import { + type FileImportImageResult, + type HostBridgeAudioMimeType, + type HostBridgeDocumentMimeType, + type HostBridgeImageMimeType, + type HostBridgeTextMimeType, + HOST_BRIDGE_AUDIO_MIME_TYPES, + HOST_BRIDGE_DOCUMENT_MIME_TYPES, + HOST_BRIDGE_IMAGE_MIME_TYPES, + HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, + HOST_BRIDGE_TEXT_MIME_TYPES, + normalizeHostBridgeExportFileName, + normalizeHostBridgeImportFileName, +} from '../../../../packages/shared/src/contracts/hostBridge'; +import { invalidRequest } from './protocol'; + +export const HOST_BRIDGE_TEXT_MIME_TYPE_SET = + new Set(HOST_BRIDGE_TEXT_MIME_TYPES); +export const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = + new Set(HOST_BRIDGE_DOCUMENT_MIME_TYPES); +export const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = + new Set(HOST_BRIDGE_IMAGE_MIME_TYPES); +export const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = + new Set(HOST_BRIDGE_AUDIO_MIME_TYPES); +export const MOBILE_AUDIO_DOCUMENT_PICKER_TYPES = [ + 'audio/*', + ...HOST_BRIDGE_AUDIO_MIME_TYPES, +]; +export const MOBILE_DOCUMENT_PICKER_TYPES = [ + 'text/*', + ...HOST_BRIDGE_DOCUMENT_MIME_TYPES, +]; + +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +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; +} + +export 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; +} + +export function base64DecodedByteLength(value: string) { + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + return Math.floor((value.length * 3) / 4) - padding; +} + +function base64Bytes(value: string) { + const bytes = new Uint8Array(base64DecodedByteLength(value)); + let byteIndex = 0; + + for (let index = 0; index < value.length; index += 4) { + const first = BASE64_ALPHABET.indexOf(value[index] ?? 'A'); + const second = BASE64_ALPHABET.indexOf(value[index + 1] ?? 'A'); + const third = + value[index + 2] === '=' + ? 0 + : BASE64_ALPHABET.indexOf(value[index + 2] ?? 'A'); + const fourth = + value[index + 3] === '=' + ? 0 + : BASE64_ALPHABET.indexOf(value[index + 3] ?? 'A'); + const chunk = (first << 18) | (second << 12) | (third << 6) | fourth; + + if (byteIndex < bytes.length) { + bytes[byteIndex] = (chunk >> 16) & 0xff; + byteIndex += 1; + } + if (byteIndex < bytes.length) { + bytes[byteIndex] = (chunk >> 8) & 0xff; + byteIndex += 1; + } + if (byteIndex < bytes.length) { + bytes[byteIndex] = chunk & 0xff; + byteIndex += 1; + } + } + + return bytes; +} + +function byteAt(bytes: Uint8Array, index: number) { + return bytes[index] ?? 0; +} + +function bytesStartWith(bytes: Uint8Array, header: readonly number[]) { + return header.every((value, index) => byteAt(bytes, index) === value); +} + +function riffContainerMatches(bytes: Uint8Array, kind: string) { + return ( + bytes.length >= 12 && + byteAt(bytes, 0) === 0x52 && + byteAt(bytes, 1) === 0x49 && + byteAt(bytes, 2) === 0x46 && + byteAt(bytes, 3) === 0x46 && + String.fromCharCode(...bytes.slice(8, 12)) === kind + ); +} + +function detectImageMimeType(base64Data: string): HostBridgeImageMimeType | null { + const bytes = base64Bytes(base64Data); + if (bytesStartWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return 'image/png'; + } + if ( + bytes.length >= 3 && + byteAt(bytes, 0) === 0xff && + byteAt(bytes, 1) === 0xd8 && + byteAt(bytes, 2) === 0xff + ) { + return 'image/jpeg'; + } + if (riffContainerMatches(bytes, 'WEBP')) { + return 'image/webp'; + } + + return null; +} + +function detectAudioMimeType(base64Data: string): HostBridgeAudioMimeType | null { + const bytes = base64Bytes(base64Data); + if ( + bytesStartWith(bytes, [0x49, 0x44, 0x33]) || + (bytes.length >= 2 && byteAt(bytes, 0) === 0xff && (byteAt(bytes, 1) & 0xe0) === 0xe0) + ) { + return 'audio/mpeg'; + } + if ( + bytes.length >= 12 && + byteAt(bytes, 4) === 0x66 && + byteAt(bytes, 5) === 0x74 && + byteAt(bytes, 6) === 0x79 && + byteAt(bytes, 7) === 0x70 + ) { + return 'audio/mp4'; + } + if (riffContainerMatches(bytes, 'WAVE')) { + return 'audio/wav'; + } + if (bytesStartWith(bytes, [0x4f, 0x67, 0x67, 0x53])) { + return 'audio/ogg'; + } + if (bytesStartWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) { + return 'audio/webm'; + } + + return null; +} + +export function ensureImageBytesMatchMimeType( + base64Data: string, + mimeType: HostBridgeImageMimeType, +) { + if (detectImageMimeType(base64Data) !== mimeType) { + throw invalidRequest('image bytes do not match MIME'); + } +} + +export function ensureAudioBytesMatchMimeType( + base64Data: string, + mimeType: HostBridgeAudioMimeType, +) { + if (detectAudioMimeType(base64Data) !== mimeType) { + throw invalidRequest('audio bytes do not match MIME'); + } +} + +function imageFileExtension(mimeType: HostBridgeImageMimeType) { + if (mimeType === 'image/jpeg') { + return 'jpg'; + } + if (mimeType === 'image/webp') { + return 'webp'; + } + return 'png'; +} + +export function normalizeExportedImageFileName( + rawFileName: unknown, + mimeType: HostBridgeImageMimeType, +) { + const fileName = normalizeHostBridgeExportFileName(rawFileName); + const extension = imageFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + +export function normalizeImportedImageFileName( + rawFileName: unknown, + mimeType: HostBridgeImageMimeType, +) { + const fileName = normalizeHostBridgeImportFileName(rawFileName); + const extension = imageFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + +export function normalizeImportedTextMimeType( + value: unknown, + fileName: string, +): HostBridgeTextMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if (HOST_BRIDGE_TEXT_MIME_TYPE_SET.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 function normalizeImportedDocumentMimeType( + value: unknown, + fileName: string, +): HostBridgeDocumentMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if ( + HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET.has( + mimeType as HostBridgeDocumentMimeType, + ) + ) { + return mimeType as HostBridgeDocumentMimeType; + } + } + + const textMimeType = normalizeImportedTextMimeType(value, fileName); + if (textMimeType) { + return textMimeType; + } + + return fileName.toLowerCase().endsWith('.docx') + ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + : null; +} + +type SizedFile = { + size?: number; +}; + +export function assertImportedFileSizeWithinLimit( + pickerSize: unknown, + file: SizedFile, + maxBytes: number, + message: string, +) { + if ( + typeof pickerSize === 'number' && + Number.isFinite(pickerSize) && + pickerSize > 0 && + pickerSize <= maxBytes + ) { + return; + } + + if (typeof pickerSize === 'number') { + throw invalidRequest(message); + } + + const fileSize = file.size; + if ( + typeof fileSize !== 'number' || + !Number.isFinite(fileSize) || + fileSize <= 0 || + fileSize > maxBytes + ) { + throw invalidRequest(message); + } +} + +export function normalizeImportedImageMimeType( + value: unknown, +): HostBridgeImageMimeType | null { + if (typeof value !== 'string') { + return null; + } + + const mimeType = value.toLowerCase(); + return HOST_BRIDGE_IMAGE_MIME_TYPE_SET.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'; +} + +export function imagePickerResultToImportPayload( + result: ImagePicker.ImagePickerResult, + action: FileImportImageResult['action'], +): FileImportImageResult { + if (result.canceled) { + throw { + code: 'cancelled', + message: 'file import cancelled', + }; + } + + 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) { + throw invalidRequest('image exceeds file import size limit'); + } + ensureImageBytesMatchMimeType(base64Data, mimeType); + if ( + typeof asset.fileSize === 'number' && + asset.fileSize > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES + ) { + throw invalidRequest('image exceeds file import size limit'); + } + + return { + action, + fileName: normalizeImportedImageFileName( + asset.fileName || fallbackImportedImageFileName(mimeType), + mimeType, + ), + base64Data, + mimeType, + bytes, + }; +} + +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'; +} + +export function normalizeExportedAudioFileName( + rawFileName: unknown, + mimeType: HostBridgeAudioMimeType, +) { + const fileName = normalizeHostBridgeExportFileName(rawFileName); + const extension = audioFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + +export function normalizeImportedAudioFileName( + rawFileName: unknown, + mimeType: HostBridgeAudioMimeType, +) { + const fileName = normalizeHostBridgeImportFileName(rawFileName); + const extension = audioFileExtension(mimeType); + return fileName.toLowerCase().endsWith(`.${extension}`) + ? fileName + : `${fileName}.${extension}`; +} + +export function normalizeImportedAudioMimeType( + value: unknown, + fileName: string, +): HostBridgeAudioMimeType | null { + if (typeof value === 'string') { + const mimeType = value.toLowerCase(); + if (HOST_BRIDGE_AUDIO_MIME_TYPE_SET.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; +} diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index a49241f7a..140a07ff4 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -15,242 +15,42 @@ import { type FileImportImageResult, type FileImportTextResult, type HostBridgeAudioMimeType, - type HostBridgeDocumentMimeType, type HostBridgeError, type HostBridgeImageMimeType, type HostBridgeRequest, type HostBridgeTextMimeType, - HOST_BRIDGE_AUDIO_MIME_TYPES, - HOST_BRIDGE_DOCUMENT_MIME_TYPES, HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES, HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES, HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES, - HOST_BRIDGE_IMAGE_MIME_TYPES, HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES, HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, - HOST_BRIDGE_TEXT_MIME_TYPES, normalizeHostBridgeExportFileName, normalizeHostBridgeImportFileName, } from '../../../../packages/shared/src/contracts/hostBridge'; +import { + assertImportedFileSizeWithinLimit, + base64DecodedByteLength, + ensureAudioBytesMatchMimeType, + ensureImageBytesMatchMimeType, + HOST_BRIDGE_AUDIO_MIME_TYPE_SET, + HOST_BRIDGE_IMAGE_MIME_TYPE_SET, + HOST_BRIDGE_TEXT_MIME_TYPE_SET, + imagePickerResultToImportPayload, + MOBILE_AUDIO_DOCUMENT_PICKER_TYPES, + MOBILE_DOCUMENT_PICKER_TYPES, + normalizedBase64Data, + normalizeExportedAudioFileName, + normalizeExportedImageFileName, + normalizeImportedAudioFileName, + normalizeImportedAudioMimeType, + normalizeImportedDocumentMimeType, + normalizeImportedTextMimeType, + utf8ByteLength, +} from './filePayloads'; import { invalidRequest, ok } from './protocol'; -const HOST_BRIDGE_TEXT_MIME_TYPE_SET = new Set( - HOST_BRIDGE_TEXT_MIME_TYPES, -); -const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET = new Set( - HOST_BRIDGE_DOCUMENT_MIME_TYPES, -); -const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set( - HOST_BRIDGE_IMAGE_MIME_TYPES, -); -const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set( - HOST_BRIDGE_AUDIO_MIME_TYPES, -); -export const MOBILE_AUDIO_DOCUMENT_PICKER_TYPES = [ - 'audio/*', - ...HOST_BRIDGE_AUDIO_MIME_TYPES, -]; -export const MOBILE_DOCUMENT_PICKER_TYPES = [ - 'text/*', - ...HOST_BRIDGE_DOCUMENT_MIME_TYPES, -]; -const BASE64_ALPHABET = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - -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; -} - -function base64Bytes(value: string) { - const bytes = new Uint8Array(base64DecodedByteLength(value)); - let byteIndex = 0; - - for (let index = 0; index < value.length; index += 4) { - const first = BASE64_ALPHABET.indexOf(value[index] ?? 'A'); - const second = BASE64_ALPHABET.indexOf(value[index + 1] ?? 'A'); - const third = - value[index + 2] === '=' - ? 0 - : BASE64_ALPHABET.indexOf(value[index + 2] ?? 'A'); - const fourth = - value[index + 3] === '=' - ? 0 - : BASE64_ALPHABET.indexOf(value[index + 3] ?? 'A'); - const chunk = (first << 18) | (second << 12) | (third << 6) | fourth; - - if (byteIndex < bytes.length) { - bytes[byteIndex] = (chunk >> 16) & 0xff; - byteIndex += 1; - } - if (byteIndex < bytes.length) { - bytes[byteIndex] = (chunk >> 8) & 0xff; - byteIndex += 1; - } - if (byteIndex < bytes.length) { - bytes[byteIndex] = chunk & 0xff; - byteIndex += 1; - } - } - - return bytes; -} - -function byteAt(bytes: Uint8Array, index: number) { - return bytes[index] ?? 0; -} - -function bytesStartWith(bytes: Uint8Array, header: readonly number[]) { - return header.every((value, index) => byteAt(bytes, index) === value); -} - -function riffContainerMatches(bytes: Uint8Array, kind: string) { - return ( - bytes.length >= 12 && - byteAt(bytes, 0) === 0x52 && - byteAt(bytes, 1) === 0x49 && - byteAt(bytes, 2) === 0x46 && - byteAt(bytes, 3) === 0x46 && - String.fromCharCode(...bytes.slice(8, 12)) === kind - ); -} - -function detectImageMimeType(base64Data: string): HostBridgeImageMimeType | null { - const bytes = base64Bytes(base64Data); - if (bytesStartWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { - return 'image/png'; - } - if ( - bytes.length >= 3 && - byteAt(bytes, 0) === 0xff && - byteAt(bytes, 1) === 0xd8 && - byteAt(bytes, 2) === 0xff - ) { - return 'image/jpeg'; - } - if (riffContainerMatches(bytes, 'WEBP')) { - return 'image/webp'; - } - - return null; -} - -function detectAudioMimeType(base64Data: string): HostBridgeAudioMimeType | null { - const bytes = base64Bytes(base64Data); - if ( - bytesStartWith(bytes, [0x49, 0x44, 0x33]) || - (bytes.length >= 2 && byteAt(bytes, 0) === 0xff && (byteAt(bytes, 1) & 0xe0) === 0xe0) - ) { - return 'audio/mpeg'; - } - if ( - bytes.length >= 12 && - byteAt(bytes, 4) === 0x66 && - byteAt(bytes, 5) === 0x74 && - byteAt(bytes, 6) === 0x79 && - byteAt(bytes, 7) === 0x70 - ) { - return 'audio/mp4'; - } - if (riffContainerMatches(bytes, 'WAVE')) { - return 'audio/wav'; - } - if (bytesStartWith(bytes, [0x4f, 0x67, 0x67, 0x53])) { - return 'audio/ogg'; - } - if (bytesStartWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) { - return 'audio/webm'; - } - - return null; -} - -function ensureImageBytesMatchMimeType( - base64Data: string, - mimeType: HostBridgeImageMimeType, -) { - if (detectImageMimeType(base64Data) !== mimeType) { - throw invalidRequest('image bytes do not match MIME'); - } -} - -function ensureAudioBytesMatchMimeType( - base64Data: string, - mimeType: HostBridgeAudioMimeType, -) { - if (detectAudioMimeType(base64Data) !== mimeType) { - throw invalidRequest('audio bytes do not match MIME'); - } -} - -function imageFileExtension(mimeType: HostBridgeImageMimeType) { - if (mimeType === 'image/jpeg') { - return 'jpg'; - } - if (mimeType === 'image/webp') { - return 'webp'; - } - return 'png'; -} - -function normalizeExportedImageFileName( - rawFileName: unknown, - mimeType: HostBridgeImageMimeType, -) { - const fileName = normalizeHostBridgeExportFileName(rawFileName); - const extension = imageFileExtension(mimeType); - return fileName.toLowerCase().endsWith(`.${extension}`) - ? fileName - : `${fileName}.${extension}`; -} - -function normalizeImportedImageFileName( - rawFileName: unknown, - mimeType: HostBridgeImageMimeType, -) { - const fileName = normalizeHostBridgeImportFileName(rawFileName); - const extension = imageFileExtension(mimeType); - return fileName.toLowerCase().endsWith(`.${extension}`) - ? fileName - : `${fileName}.${extension}`; -} - export async function exportTextFile( payload: unknown, ): Promise { @@ -303,89 +103,6 @@ export async function exportMobileHostBridgeTextFile( return ok(request, await exportTextFile(request.payload)); } -function normalizeImportedTextMimeType( - value: unknown, - fileName: string, -): HostBridgeTextMimeType | null { - if (typeof value === 'string') { - const mimeType = value.toLowerCase(); - if (HOST_BRIDGE_TEXT_MIME_TYPE_SET.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; -} - -function normalizeImportedDocumentMimeType( - value: unknown, - fileName: string, -): HostBridgeDocumentMimeType | null { - if (typeof value === 'string') { - const mimeType = value.toLowerCase(); - if ( - HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET.has( - mimeType as HostBridgeDocumentMimeType, - ) - ) { - return mimeType as HostBridgeDocumentMimeType; - } - } - - const textMimeType = normalizeImportedTextMimeType(value, fileName); - if (textMimeType) { - return textMimeType; - } - - return fileName.toLowerCase().endsWith('.docx') - ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - : null; -} - -function assertImportedFileSizeWithinLimit( - pickerSize: unknown, - file: File, - maxBytes: number, - message: string, -) { - if ( - typeof pickerSize === 'number' && - Number.isFinite(pickerSize) && - pickerSize > 0 && - pickerSize <= maxBytes - ) { - return; - } - - if (typeof pickerSize === 'number') { - throw invalidRequest(message); - } - - const fileSize = file.size; - if ( - typeof fileSize !== 'number' || - !Number.isFinite(fileSize) || - fileSize <= 0 || - fileSize > maxBytes - ) { - throw invalidRequest(message); - } -} - export async function importTextFile(): Promise { const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, @@ -552,79 +269,6 @@ export async function exportMobileHostBridgeImageFile( return ok(request, await exportImageFile(request.payload)); } -function normalizeImportedImageMimeType( - value: unknown, -): HostBridgeImageMimeType | null { - if (typeof value !== 'string') { - return null; - } - - const mimeType = value.toLowerCase(); - return HOST_BRIDGE_IMAGE_MIME_TYPE_SET.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 > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) { - throw invalidRequest('image exceeds file import size limit'); - } - ensureImageBytesMatchMimeType(base64Data, mimeType); - if ( - typeof asset.fileSize === 'number' && - asset.fileSize > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES - ) { - throw invalidRequest('image exceeds file import size limit'); - } - - return { - action, - fileName: normalizeImportedImageFileName( - asset.fileName || fallbackImportedImageFileName(mimeType), - mimeType, - ), - base64Data, - mimeType, - bytes, - }; -} - export async function importImageFile(): Promise { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permission.status !== ImagePicker.PermissionStatus.GRANTED) { @@ -643,7 +287,11 @@ export async function importImageFile(): Promise { quality: 1, }); - return imagePickerResultToImportPayload(result, 'selected'); + const payload = imagePickerResultToImportPayload(result, 'selected'); + if (payload.bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) { + throw invalidRequest('image exceeds file import size limit'); + } + return payload; } export async function importMobileHostBridgeImageFile( @@ -669,7 +317,11 @@ export async function captureImageFile(): Promise { quality: 1, }); - return imagePickerResultToImportPayload(result, 'captured'); + const payload = imagePickerResultToImportPayload(result, 'captured'); + if (payload.bytes > HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES) { + throw invalidRequest('image exceeds file import size limit'); + } + return payload; } export async function captureMobileHostBridgeImageFile( @@ -678,75 +330,6 @@ export async function captureMobileHostBridgeImageFile( return ok(request, await captureImageFile()); } -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 normalizeImportedAudioFileName( - rawFileName: unknown, - mimeType: HostBridgeAudioMimeType, -) { - const fileName = normalizeHostBridgeImportFileName(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_TYPE_SET.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 { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 12fa4a62b..5c34222ab 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -39,6 +39,7 @@ - 2026-06-19 原生壳分享桥接边界:Expo `share.setTarget` / `share.open` 的缓存目标、分享 payload 归一、系统分享调用和 HostBridge 响应统一收口在 `apps/mobile-shell/src/host-bridge/share.ts`;Tauri `share.setTarget` / `share.open` 的缓存目标、分享文本生成、剪贴板 fallback 写入和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/share.rs`。两端 `dispatch` 只负责委托对应 share 模块,配置检查会拒绝分发层直接持有分享状态、生成分享文本、写入分享剪贴板结果或包装分享成功响应。 - 2026-06-19 桌面壳窗口标题桥接边界:Tauri `app.setTitle` 的 payload 校验、非空 / 控制字符拒绝、80 字符截断和主窗口 `set_title` 调用统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;`dispatch.rs` 只负责委托 `set_desktop_host_bridge_window_title(...)`。桌面壳配置检查和根级结构门禁会覆盖 `title.rs` 文件清单、共享标题长度镜像和 dispatch 委托关系。 - 2026-06-19 桌面壳文件桥接执行边界:Tauri `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.importAudio` / `file.exportAudio` 的系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`;MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 组装统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`;`dispatch.rs` 只负责按 method 委托 `export_desktop_host_bridge_*_file(...)` / `import_desktop_host_bridge_*_file(...)`。桌面壳配置检查会拒绝分发层直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper,避免文件访问边界重新散落。 +- 2026-06-20 移动壳文件桥接载荷边界:Expo `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.captureImage` / `file.importAudio` / `file.exportAudio` 的 DocumentPicker、ImagePicker、File、Sharing 系统交互、用户取消语义、缓存读写编排和 HostBridge 响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`;MIME、大小、base64、文件名清洗、图片 / 音频 bytes 匹配和 picker 结果到 HostBridge payload 的组装统一收口在 `apps/mobile-shell/src/host-bridge/filePayloads.ts`。移动壳单端配置检查和根级 `npm run check:native-shells` 会把 `filePayloads.ts` 纳入结构清单与 HostBridge 源码扫描,避免文件载荷边界重新散落到分发层或 shell 层。 - 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。 - 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()` 在 `native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。 - 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 1915f9c13..9e30990ed 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -64,13 +64,13 @@ src/ 已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。 -三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` / `share.ts` / `scanner.ts` / `notifications.ts` 分别承接文件、分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` 承接系统文件对话框、取消语义和异步读写编排,`file_payloads.rs` 承接文件 MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 边界,`share.rs` / `notifications.rs` 分别承接分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。 +三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` 承接 Expo DocumentPicker / ImagePicker / File / Sharing 系统交互、取消语义、读写编排和 HostBridge 响应包装,`filePayloads.ts` 承接文件 MIME、大小、base64、文件名清洗和 picker 结果到 HostBridge payload 的边界,`share.ts` / `scanner.ts` / `notifications.ts` 分别承接分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` 承接系统文件对话框、取消语义和异步读写编排,`file_payloads.rs` 承接文件 MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 边界,`share.rs` / `notifications.rs` 分别承接分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。 -当前 `npm run check:native-shells` 锁定的生产文件清单为:微信桥接层 `dispatch.js`、`payment.js`、`protocol.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信 shell 层 `payment.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信页面包装层 `share-grid/index.js`、`share-grid/index.json`、`share-grid/index.wxml`、`share-grid/index.wxss`、`subscribe-message/index.js`、`subscribe-message/index.json`、`subscribe-message/index.wxml`、`subscribe-message/index.wxss`、`web-view/index.js`、`web-view/index.json`、`web-view/index.wxml`、`web-view/index.wxss`、`wechat-pay/index.js`、`wechat-pay/index.json`、`wechat-pay/index.wxml`、`wechat-pay/index.wxss`;移动源码根 `env.d.ts`;移动桥接层 `appearance.ts`、`badge.ts`、`bridge.ts`、`capabilities.ts`、`clipboard.ts`、`dispatch.ts`、`files.ts`、`haptics.ts`、`navigation.ts`、`network.ts`、`notifications.ts`、`protocol.ts`、`runtime.ts`、`scanner.ts`、`share.ts`;移动 shell 层 `QrScannerOverlay.tsx`、`ShellApp.tsx`、`deepLink.ts`、`lifecycle.ts`、`loadFailure.ts`、`navigation.ts`、`network.ts`、`runtime.ts`、`safeArea.ts`、`url.ts`、`webViewGlobals.d.ts`、`webViewHistory.ts`、`webViewPolicy.ts`;桌面入口 `app.rs`、`main.rs`;桌面桥接层 `appearance.rs`、`badge.rs`、`capabilities.rs`、`clipboard.rs`、`dispatch.rs`、`files.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`notifications.rs`、`protocol.rs`、`runtime.rs`、`share.rs`、`title.rs`;桌面 shell 层 `deep_link.rs`、`events.rs`、`file_drop.rs`、`lifecycle.rs`、`menu.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`runtime.rs`、`tray.rs`、`url.rs`、`webview.rs`、`window_state.rs`。 +当前 `npm run check:native-shells` 锁定的生产文件清单为:微信桥接层 `dispatch.js`、`payment.js`、`protocol.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信 shell 层 `payment.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信页面包装层 `share-grid/index.js`、`share-grid/index.json`、`share-grid/index.wxml`、`share-grid/index.wxss`、`subscribe-message/index.js`、`subscribe-message/index.json`、`subscribe-message/index.wxml`、`subscribe-message/index.wxss`、`web-view/index.js`、`web-view/index.json`、`web-view/index.wxml`、`web-view/index.wxss`、`wechat-pay/index.js`、`wechat-pay/index.json`、`wechat-pay/index.wxml`、`wechat-pay/index.wxss`;移动源码根 `env.d.ts`;移动桥接层 `appearance.ts`、`badge.ts`、`bridge.ts`、`capabilities.ts`、`clipboard.ts`、`dispatch.ts`、`filePayloads.ts`、`files.ts`、`haptics.ts`、`navigation.ts`、`network.ts`、`notifications.ts`、`protocol.ts`、`runtime.ts`、`scanner.ts`、`share.ts`;移动 shell 层 `QrScannerOverlay.tsx`、`ShellApp.tsx`、`deepLink.ts`、`lifecycle.ts`、`loadFailure.ts`、`navigation.ts`、`network.ts`、`runtime.ts`、`safeArea.ts`、`url.ts`、`webViewGlobals.d.ts`、`webViewHistory.ts`、`webViewPolicy.ts`;桌面入口 `app.rs`、`main.rs`;桌面桥接层 `appearance.rs`、`badge.rs`、`capabilities.rs`、`clipboard.rs`、`dispatch.rs`、`files.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`notifications.rs`、`protocol.rs`、`runtime.rs`、`share.rs`、`title.rs`;桌面 shell 层 `deep_link.rs`、`events.rs`、`file_drop.rs`、`lifecycle.rs`、`menu.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`runtime.rs`、`tray.rs`、`url.rs`、`webview.rs`、`window_state.rs`。 生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。 -结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/menu.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 +结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/filePayloads.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/menu.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 ## HostBridge 消息协议 @@ -521,7 +521,7 @@ GameBridge 禁止: 2026-06-18 追加:`app.openExternalUrl` 的协议白名单以共享 HostBridge 契约 `HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 为唯一来源,当前只允许 `http:`、`https:`、`mailto:`、`tel:`。Expo 壳直接复用共享归一化逻辑,Tauri 壳 Rust 侧用 URL parser 镜像同一清单;`npm run check:native-shells` 会反查共享契约与桌面壳协议清单,防止某一端单独放宽外链协议。 -2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`capabilities.ts`、`dispatch.ts`、`files.ts`、`scanner.ts`、`share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs`、`capabilities.rs`、`dispatch.rs`、`files.rs`、`share.rs`、`mod.rs` 对齐;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期、安全区、扫码 overlay 和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/app.rs`、`host_bridge/*.rs` 与 `shell/*.rs`,其中 `app.rs` 承接 Tauri builder / plugin / window 装配,`runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,薄 `main.rs` 只声明模块并调用 `app::run()`。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。 +2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts`、`capabilities.ts`、`dispatch.ts`、`files.ts`、`filePayloads.ts`、`scanner.ts`、`share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs`、`capabilities.rs`、`dispatch.rs`、`files.rs`、`file_payloads.rs`、`share.rs`、`mod.rs` 对齐,其中移动 `files.ts` 只承接 Expo 系统文件交互和 HostBridge 响应包装,`filePayloads.ts` 承接 MIME、大小、base64、文件名和 picker payload 边界;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期、安全区、扫码 overlay 和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/app.rs`、`host_bridge/*.rs` 与 `shell/*.rs`,其中 `app.rs` 承接 Tauri builder / plugin / window 装配,`runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,薄 `main.rs` 只声明模块并调用 `app::run()`。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。 2026-06-19 追加:HostBridge 载荷边界以共享契约为单一声明来源。`packages/shared/src/contracts/hostBridge.ts` 导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback 与长度上限,以及 request id、角标、剪贴板和本地通知文本长度边界;Expo 移动壳必须直接导入这些共享常量,不再本地重声明文件大小或 MIME 清单,并且文本 / 音频导入必须在读取内容前通过 picker `size` 或 Expo `File.size` 完成大小门禁,无法拿到可信 byte count 时直接拒绝导入;Tauri 桌面壳的配置检查会反查 Rust 镜像实现,拒绝文件大小、MIME 清单、文件名、通知、剪贴板或 request id 边界与共享契约漂移。新增文件类型或调整体积上限必须先更新共享契约、壳实现和门禁,再进入玩法或 H5 facade。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index c4969fd3f..e99ead15e 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -37,13 +37,13 @@ AI H5 sandbox -> parent HostBridge adapter ``` -桥接层文件结构按宿主统一为“协议 / 能力清单 / 分发 / 宿主容器行为”四类职责。微信小程序不硬套 Expo / Tauri 的 request 总线:`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`;`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` / `share.ts` / `scanner.ts` / `notifications.ts` 分别承接文件、分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` 承接系统文件对话框、取消语义和异步读写编排,`file_payloads.rs` 承接文件 MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 边界,`share.rs` / `notifications.rs` 分别承接分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。`npm run check:native-shells` 会检查这些目录清单。 +桥接层文件结构按宿主统一为“协议 / 能力清单 / 分发 / 宿主容器行为”四类职责。微信小程序不硬套 Expo / Tauri 的 request 总线:`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`;`miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`appearance.ts` 承接系统配色读取,`navigation.ts` 承接外链打开、受控 H5 跳转和 WebView 刷新,`network.ts` 承接网络状态查询,`badge.ts` 承接受控角标能力,`clipboard.ts` 承接剪贴板读写与 HostBridge payload / 响应边界,`files.ts` 承接 Expo DocumentPicker / ImagePicker / File / Sharing 系统交互、取消语义、读写编排和 HostBridge 响应包装,`filePayloads.ts` 承接文件 MIME、大小、base64、文件名清洗和 picker 结果到 HostBridge payload 的边界,`share.ts` / `scanner.ts` / `notifications.ts` 分别承接分享、扫码和本地通知能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区、扫码 overlay 和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`runtime.rs` 承接桌面 runtime 回包的平台、hostVersion、bridgeVersion 和 capability 清单组装,`appearance.rs` 承接窗口主题读取和 HostBridge 配色归一,`navigation.rs` 承接外链打开、受控 H5 跳转和主窗口刷新,`network.rs` 承接网络状态查询,`badge.rs` 承接受控任务栏角标能力,`clipboard.rs` 承接剪贴板读写与 HostBridge payload / 响应边界,`title.rs` 承接窗口标题 payload / 响应边界,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` 承接系统文件对话框、取消语义和异步读写编排,`file_payloads.rs` 承接文件 MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 边界,`share.rs` / `notifications.rs` 分别承接分享和本地通知能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`apps/desktop-shell/src-tauri/src/app.rs` 承接 Tauri builder / plugin / window 装配,`main.rs` 只保留薄入口并调用 `app::run()`。`npm run check:native-shells` 会检查这些目录清单。 -当前 `npm run check:native-shells` 锁定的生产文件清单为:微信桥接层 `dispatch.js`、`payment.js`、`protocol.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信 shell 层 `payment.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信页面包装层 `share-grid/index.js`、`share-grid/index.json`、`share-grid/index.wxml`、`share-grid/index.wxss`、`subscribe-message/index.js`、`subscribe-message/index.json`、`subscribe-message/index.wxml`、`subscribe-message/index.wxss`、`web-view/index.js`、`web-view/index.json`、`web-view/index.wxml`、`web-view/index.wxss`、`wechat-pay/index.js`、`wechat-pay/index.json`、`wechat-pay/index.wxml`、`wechat-pay/index.wxss`;移动源码根 `env.d.ts`;移动桥接层 `appearance.ts`、`badge.ts`、`bridge.ts`、`capabilities.ts`、`clipboard.ts`、`dispatch.ts`、`files.ts`、`haptics.ts`、`navigation.ts`、`network.ts`、`notifications.ts`、`protocol.ts`、`runtime.ts`、`scanner.ts`、`share.ts`;移动 shell 层 `QrScannerOverlay.tsx`、`ShellApp.tsx`、`deepLink.ts`、`lifecycle.ts`、`loadFailure.ts`、`navigation.ts`、`network.ts`、`runtime.ts`、`safeArea.ts`、`url.ts`、`webViewGlobals.d.ts`、`webViewHistory.ts`、`webViewPolicy.ts`;桌面入口 `app.rs`、`main.rs`;桌面桥接层 `appearance.rs`、`badge.rs`、`capabilities.rs`、`clipboard.rs`、`dispatch.rs`、`files.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`notifications.rs`、`protocol.rs`、`runtime.rs`、`share.rs`、`title.rs`;桌面 shell 层 `deep_link.rs`、`events.rs`、`file_drop.rs`、`lifecycle.rs`、`menu.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`runtime.rs`、`tray.rs`、`url.rs`、`webview.rs`、`window_state.rs`。 +当前 `npm run check:native-shells` 锁定的生产文件清单为:微信桥接层 `dispatch.js`、`payment.js`、`protocol.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信 shell 层 `payment.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信页面包装层 `share-grid/index.js`、`share-grid/index.json`、`share-grid/index.wxml`、`share-grid/index.wxss`、`subscribe-message/index.js`、`subscribe-message/index.json`、`subscribe-message/index.wxml`、`subscribe-message/index.wxss`、`web-view/index.js`、`web-view/index.json`、`web-view/index.wxml`、`web-view/index.wxss`、`wechat-pay/index.js`、`wechat-pay/index.json`、`wechat-pay/index.wxml`、`wechat-pay/index.wxss`;移动源码根 `env.d.ts`;移动桥接层 `appearance.ts`、`badge.ts`、`bridge.ts`、`capabilities.ts`、`clipboard.ts`、`dispatch.ts`、`filePayloads.ts`、`files.ts`、`haptics.ts`、`navigation.ts`、`network.ts`、`notifications.ts`、`protocol.ts`、`runtime.ts`、`scanner.ts`、`share.ts`;移动 shell 层 `QrScannerOverlay.tsx`、`ShellApp.tsx`、`deepLink.ts`、`lifecycle.ts`、`loadFailure.ts`、`navigation.ts`、`network.ts`、`runtime.ts`、`safeArea.ts`、`url.ts`、`webViewGlobals.d.ts`、`webViewHistory.ts`、`webViewPolicy.ts`;桌面入口 `app.rs`、`main.rs`;桌面桥接层 `appearance.rs`、`badge.rs`、`capabilities.rs`、`clipboard.rs`、`dispatch.rs`、`files.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`notifications.rs`、`protocol.rs`、`runtime.rs`、`share.rs`、`title.rs`;桌面 shell 层 `deep_link.rs`、`events.rs`、`file_drop.rs`、`lifecycle.rs`、`menu.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`runtime.rs`、`tray.rs`、`url.rs`、`webview.rs`、`window_state.rs`。 生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。 -结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/menu.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 +结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/filePayloads.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/menu.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗口配置,并在创建 WebView 前补写 `native_app`、`tauri_desktop` 和真实 capability 上下文;缺少主窗口配置时启动直接失败,不允许按 `windows[0]` 兜底或无主窗口静默运行。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index c146843f9..cb763212d 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -256,6 +256,7 @@ const expectedMobileHostBridgeFiles = [ 'capabilities.ts', 'clipboard.ts', 'dispatch.ts', + 'filePayloads.ts', 'files.ts', 'haptics.ts', 'navigation.ts',