拆分移动壳文件载荷边界

移动壳文件执行与载荷校验拆成 files 和 filePayloads

移动壳单端门禁登记文件载荷模块

宿主壳方案和统一协议同步移动文件桥接分层
This commit is contained in:
2026-06-20 07:11:57 +08:00
parent 823186462b
commit cde936bd6d
7 changed files with 503 additions and 454 deletions
@@ -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',
@@ -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<HostBridgeTextMimeType>(HOST_BRIDGE_TEXT_MIME_TYPES);
export const HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET =
new Set<HostBridgeDocumentMimeType>(HOST_BRIDGE_DOCUMENT_MIME_TYPES);
export const HOST_BRIDGE_IMAGE_MIME_TYPE_SET =
new Set<HostBridgeImageMimeType>(HOST_BRIDGE_IMAGE_MIME_TYPES);
export const HOST_BRIDGE_AUDIO_MIME_TYPE_SET =
new Set<HostBridgeAudioMimeType>(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;
}
File diff suppressed because it is too large Load Diff
@@ -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 错误回传给 H5WebView 拦截保持 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` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -256,6 +256,7 @@ const expectedMobileHostBridgeFiles = [
'capabilities.ts',
'clipboard.ts',
'dispatch.ts',
'filePayloads.ts',
'files.ts',
'haptics.ts',
'navigation.ts',