拆分移动壳文件载荷边界
移动壳文件执行与载荷校验拆成 files 和 filePayloads 移动壳单端门禁登记文件载荷模块 宿主壳方案和统一协议同步移动文件桥接分层
This commit is contained in:
@@ -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 错误回传给 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` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -256,6 +256,7 @@ const expectedMobileHostBridgeFiles = [
|
||||
'capabilities.ts',
|
||||
'clipboard.ts',
|
||||
'dispatch.ts',
|
||||
'filePayloads.ts',
|
||||
'files.ts',
|
||||
'haptics.ts',
|
||||
'navigation.ts',
|
||||
|
||||
Reference in New Issue
Block a user