收紧原生壳载荷边界门禁

导出 HostBridge 文件和通知载荷边界常量
校验移动壳文件大小和 MIME 清单与共享契约一致
校验桌面壳载荷限制和 MIME 清单与共享契约一致
更新原生壳方案和共享决策记录
This commit is contained in:
2026-06-19 00:26:18 +08:00
parent 1f5c752302
commit 759b2cb388
5 changed files with 369 additions and 9 deletions
+202 -5
View File
@@ -645,7 +645,7 @@ function extractStringArrayExport(source, exportName, seen = new Set()) {
}
const match = source.match(
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\][^;]*;`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
@@ -690,14 +690,14 @@ function extractRustStringConst(source, constName) {
function extractRustNumberConst(source, constName) {
const match = source.match(
new RegExp(
`(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*(?:u8|u16|u32|usize|i64|i32)\\s*=\\s*(\\d+);`,
`(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*(?:u8|u16|u32|u64|usize|i64|i32)\\s*=\\s*([^;]+);`,
),
);
if (!match) {
throw new Error(`unable to read Rust const ${constName}`);
}
return Number(match[1]);
return evaluateNumberExpression(match[1]);
}
function extractTsStringConst(source, constName) {
@@ -713,13 +713,54 @@ function extractTsStringConst(source, constName) {
function extractTsNumberConst(source, constName) {
const match = source.match(
new RegExp(`export const ${constName}\\s*=\\s*(\\d+);`),
new RegExp(`export const ${constName}\\s*=\\s*([^;]+);`),
);
if (!match) {
throw new Error(`unable to read TypeScript const ${constName}`);
}
return Number(match[1]);
return evaluateNumberExpression(match[1]);
}
function evaluateNumberExpression(expression) {
const tokens = expression
.split('*')
.map((token) => token.trim())
.filter(Boolean);
if (
tokens.length === 0 ||
tokens.some((token) => !/^\d+$/.test(token))
) {
throw new Error(`unsupported numeric expression ${expression}`);
}
return tokens.reduce((value, token) => value * Number(token), 1);
}
function extractRustStringMatchArms(source, functionName) {
const match = source.match(
new RegExp(
`(?:pub\\(crate\\)\\s+)?fn ${functionName}[^\\{]*\\{[\\s\\S]*?match [^\\{]*\\{([\\s\\S]*?)\\n \\}`,
),
);
if (!match) {
throw new Error(`unable to read Rust match function ${functionName}`);
}
return [...match[1].matchAll(/"([^"]+)"\s*=>/g)].map((entry) => entry[1]);
}
function extractRustSomeStringValues(source, functionName) {
const match = source.match(
new RegExp(`(?:pub\\(crate\\)\\s+)?fn ${functionName}[^\\{]*\\{([\\s\\S]*?)\\n\\}`),
);
if (!match) {
throw new Error(`unable to read Rust function ${functionName}`);
}
return [...match[1].matchAll(/=>\s*Some\("([^"]+)"\)/g)].map(
(entry) => entry[1],
);
}
function extractDesktopCapabilities(source) {
@@ -891,6 +932,106 @@ const sharedHostBridgeVersion = extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_VERSION',
);
const sharedHostBridgePayloadLimits = {
HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH',
),
HOST_BRIDGE_BADGE_COUNT_MAX: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_BADGE_COUNT_MAX',
),
HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH',
),
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH',
),
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH',
),
HOST_BRIDGE_FILE_NAME_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_FILE_NAME_MAX_LENGTH',
),
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
),
};
const desktopHostBridgePayloadLimits = {
HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH',
),
HOST_BRIDGE_BADGE_COUNT_MAX: extractRustNumberConst(
rustHostSource,
'BADGE_COUNT_MAX',
),
HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'CLIPBOARD_TEXT_MAX_LENGTH',
),
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'LOCAL_NOTIFICATION_TITLE_MAX_LENGTH',
),
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'LOCAL_NOTIFICATION_BODY_MAX_LENGTH',
),
HOST_BRIDGE_FILE_NAME_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'EXPORT_FILE_NAME_MAX_LENGTH',
),
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'EXPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'IMPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'EXPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'IMPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'EXPORT_AUDIO_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'IMPORT_AUDIO_MAX_BYTES',
),
};
const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS');
const desktopHostBridgeProtocol = extractRustStringConst(
rustHostSource,
@@ -919,6 +1060,62 @@ if (desktopHostBridgeVersion !== sharedHostBridgeVersion) {
);
}
for (const [limitName, sharedLimit] of Object.entries(
sharedHostBridgePayloadLimits,
)) {
const desktopLimit = desktopHostBridgePayloadLimits[limitName];
if (desktopLimit !== sharedLimit) {
throw new Error(
`desktop shell ${limitName} drifted: expected ${sharedLimit} but got ${desktopLimit}`,
);
}
}
if (
extractRustStringConst(rustHostSource, 'EXPORT_FILE_NAME_FALLBACK') !==
extractTsStringConst(sharedContractSource, 'HOST_BRIDGE_FILE_NAME_FALLBACK')
) {
throw new Error('desktop shell file name fallback drifted from shared HostBridge contract');
}
const sharedTextMimeTypes = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_TEXT_MIME_TYPES',
);
const sharedImageMimeTypes = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_IMAGE_MIME_TYPES',
);
const sharedAudioMimeTypes = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_AUDIO_MIME_TYPES',
);
assertSameList(
extractRustSomeStringValues(rustHostSource, 'import_text_mime_type'),
sharedTextMimeTypes,
'desktop shell text MIME types',
);
assertSameList(
extractRustStringMatchArms(rustHostSource, 'export_image_extension'),
sharedImageMimeTypes,
'desktop shell export image MIME types',
);
assertSameList(
extractRustSomeStringValues(rustHostSource, 'import_image_mime_type'),
sharedImageMimeTypes,
'desktop shell import image MIME types',
);
assertSameList(
extractRustStringMatchArms(rustHostSource, 'export_audio_extension'),
sharedAudioMimeTypes,
'desktop shell export audio MIME types',
);
assertSameList(
extractRustSomeStringValues(rustHostSource, 'import_audio_mime_type'),
sharedAudioMimeTypes,
'desktop shell import audio MIME types',
);
assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist');
const unknownHandledDesktopMethods = desktopHandledMethods.filter(
(method) => !sharedMethods.includes(method),
+126 -1
View File
@@ -140,7 +140,7 @@ function extractStringArrayExport(source, exportName, seen = new Set()) {
}
const match = source.match(
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\][^;]*;`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
@@ -182,6 +182,52 @@ function extractNumberConstExport(source, exportName) {
return Number(match[1]);
}
function evaluateNumberExpression(expression) {
const tokens = expression
.split('*')
.map((token) => token.trim())
.filter(Boolean);
if (
tokens.length === 0 ||
tokens.some((token) => !/^\d+$/.test(token))
) {
throw new Error(`unsupported numeric expression ${expression}`);
}
return tokens.reduce((value, token) => value * Number(token), 1);
}
function extractNumberExpressionConstExport(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*([^;]+);`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return evaluateNumberExpression(match[1]);
}
function extractLocalNumberConst(source, constName) {
const match = source.match(new RegExp(`const ${constName}\\s*=\\s*([^;]+);`));
if (!match) {
throw new Error(`unable to read local const ${constName}`);
}
return evaluateNumberExpression(match[1]);
}
function extractStringSetConst(source, constName) {
const match = source.match(
new RegExp(`const ${constName}[^=]*= new Set[^\\[]*\\[([\\s\\S]*?)\\]\\);`),
);
if (!match) {
throw new Error(`unable to read string Set ${constName}`);
}
return [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]);
}
function extractMobileBridgeHandledMethods(source) {
const match = source.match(
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
@@ -487,6 +533,58 @@ const sharedHostBridgeVersion = extractNumberConstExport(
sharedContractSource,
'HOST_BRIDGE_VERSION',
);
const sharedHostBridgePayloadLimits = {
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractNumberExpressionConstExport(
sharedContractSource,
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractNumberExpressionConstExport(
sharedContractSource,
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractNumberExpressionConstExport(
sharedContractSource,
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractNumberExpressionConstExport(
sharedContractSource,
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractNumberExpressionConstExport(
sharedContractSource,
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractNumberExpressionConstExport(
sharedContractSource,
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
),
};
const mobileHostBridgePayloadLimits = {
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractLocalNumberConst(
hostBridgeSource,
'EXPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractLocalNumberConst(
hostBridgeSource,
'IMPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractLocalNumberConst(
hostBridgeSource,
'EXPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractLocalNumberConst(
hostBridgeSource,
'IMPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractLocalNumberConst(
hostBridgeSource,
'EXPORT_AUDIO_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractLocalNumberConst(
hostBridgeSource,
'IMPORT_AUDIO_MAX_BYTES',
),
};
const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource);
const mobileCapabilities = extractStringArrayExport(
hostBridgeSource,
@@ -499,6 +597,33 @@ const iosMobileCapabilities = extractStringArrayExport(
const mobileCapabilitySet = new Set(mobileCapabilities);
const iosMobileCapabilitySet = new Set(iosMobileCapabilities);
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
for (const [limitName, sharedLimit] of Object.entries(
sharedHostBridgePayloadLimits,
)) {
const mobileLimit = mobileHostBridgePayloadLimits[limitName];
if (mobileLimit !== sharedLimit) {
throw new Error(
`mobile shell ${limitName} drifted: expected ${sharedLimit} but got ${mobileLimit}`,
);
}
}
assertSameList(
extractStringSetConst(hostBridgeSource, 'HOST_BRIDGE_TEXT_MIME_TYPES'),
extractStringArrayExport(sharedContractSource, 'HOST_BRIDGE_TEXT_MIME_TYPES'),
'mobile shell text MIME types',
);
assertSameList(
extractStringSetConst(hostBridgeSource, 'HOST_BRIDGE_IMAGE_MIME_TYPES'),
extractStringArrayExport(sharedContractSource, 'HOST_BRIDGE_IMAGE_MIME_TYPES'),
'mobile shell image MIME types',
);
assertSameList(
extractStringSetConst(hostBridgeSource, 'HOST_BRIDGE_AUDIO_MIME_TYPES'),
extractStringArrayExport(sharedContractSource, 'HOST_BRIDGE_AUDIO_MIME_TYPES'),
'mobile shell audio MIME types',
);
const unknownHandledMobileMethods = handledMobileMethods.filter(
(method) => !sharedMethods.includes(method),
);
@@ -2498,6 +2498,13 @@
- 影响范围:`miniprogram/host-bridge/``miniprogram/pages/*/index.js``apps/mobile-shell/src/``apps/desktop-shell/src-tauri/src/``scripts/check-native-shells.mjs`、宿主壳方案文档。
- 验证方式:`npm run test -- miniprogram/host-bridge/webView.test.js miniprogram/host-bridge/payment.test.js miniprogram/host-bridge/shareGrid.test.js miniprogram/host-bridge/subscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js``npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-19 HostBridge 载荷边界单一来源
- 背景:文件导入导出、剪贴板、角标、本地通知和 request id 都已经在 Expo 与 Tauri 两套壳里有运行时校验;如果 MIME 清单、字节上限或文本长度只靠人工同步,新增文件类型或调整上限时会出现 H5 契约、移动壳和桌面壳互相漂移。
- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳与 Tauri 桌面壳可以按各自宿主语言镜像实现,但 `apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。
- 影响范围:`packages/shared/src/contracts/hostBridge.ts``apps/mobile-shell/src/host-bridge/files.ts``apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/src-tauri/src/host_bridge/``apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run mobile-shell:typecheck``npm run desktop-shell:typecheck``npm run test -- packages/shared/src/contracts/hostBridge.test.ts``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-18 方洞结果页图片槽位接入原生壳图片导入
- 背景:方洞结果页的封面、背景、形状和洞口图片槽位已经支持浏览器文件输入、历史图选择、AI 生成和自动保存,但 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控 `file.importImage` 能力。
@@ -241,8 +241,9 @@ GameBridge 禁止:
- 壳层只接受来自允许 origin / packaged asset 的消息。
- H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。
- 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。
- HostBridge 的文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 与 Tauri 壳可以按宿主语言镜像实现,但必须由配置门禁反查共享契约。
- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。
- 壳能力声明与三端壳验收必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行微信壳测试集合、`npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run mobile-shell:config``npm run mobile-shell:export``npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、微信 WebView / 支付 / 订阅 / 分享桥接行为、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。
- 壳能力声明与三端壳验收必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行微信壳测试集合、`npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run mobile-shell:config``npm run mobile-shell:export``npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、文件载荷边界、微信 WebView / 支付 / 订阅 / 分享桥接行为、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。
- Expo SDK、React Native、`react-native-webview`、Tauri CLI、Tauri Rust crate 和桌面 Cargo 插件版本属于宿主壳行为边界。升级这些依赖前必须同步更新壳配置检查、`package-lock.json` / `Cargo.lock` 解析版本、本文档和对应验证结果,不能只改 package / Cargo 版本让生产壳行为静默漂移。
- 登录和支付能力在真实 SDK、渠道流程、后端契约和失败回退全部落地前不得进入 Expo / Tauri capabilities,也不得写进入口 URL `hostCapabilities`;两端配置检查会拒绝 `auth.requestLogin``payment.request` 的伪声明。
- 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。
@@ -453,6 +454,8 @@ GameBridge 禁止:
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``files.ts``share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs``files.rs``share.rs``mod.rs` 对齐;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs``apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `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` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。
2026-06-19 追加:HostBridge 载荷边界以共享契约为单一声明来源。`packages/shared/src/contracts/hostBridge.ts` 导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback 与长度上限,以及 request id、角标、剪贴板和本地通知文本长度边界;Expo 移动壳与 Tauri 桌面壳的配置检查会反查各自实现,拒绝文件大小、MIME 清单、文件名、通知、剪贴板或 request id 边界与共享契约漂移。新增文件类型或调整体积上限必须先更新共享契约、壳实现和门禁,再进入玩法或 H5 facade。
### Phase 4:宿主能力扩展
- 移动端接入系统分享、推送、原生登录和渠道支付。
+30 -2
View File
@@ -329,6 +329,13 @@ export type HostBridgeTextMimeType =
| 'text/csv'
| 'application/json';
export const HOST_BRIDGE_TEXT_MIME_TYPES = [
'text/plain',
'text/markdown',
'text/csv',
'application/json',
] as const satisfies readonly HostBridgeTextMimeType[];
export type FileImportTextResult = {
action: 'selected';
fileName: string;
@@ -354,6 +361,12 @@ export type HostBridgeImageMimeType =
| 'image/jpeg'
| 'image/webp';
export const HOST_BRIDGE_IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/webp',
] as const satisfies readonly HostBridgeImageMimeType[];
export type FileImportImageResult = {
action: 'selected' | 'dropped' | 'captured';
fileName: string;
@@ -373,6 +386,14 @@ export type HostBridgeAudioMimeType =
| 'audio/ogg'
| 'audio/webm';
export const HOST_BRIDGE_AUDIO_MIME_TYPES = [
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/webm',
] as const satisfies readonly HostBridgeAudioMimeType[];
export type FileImportAudioResult = {
action: 'selected';
fileName: string;
@@ -393,6 +414,13 @@ export type FileExportAudioResult = {
bytes: number;
};
export const HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
export const HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
export const HOST_BRIDGE_HAPTICS_IMPACT_STYLES = [
'light',
'medium',
@@ -486,8 +514,8 @@ export type ShareOpenPayload = {
url?: string;
};
const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt';
const HOST_BRIDGE_FILE_NAME_MAX_LENGTH = 120;
export const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt';
export const HOST_BRIDGE_FILE_NAME_MAX_LENGTH = 120;
function isHostBridgeInvalidFileNameCharacter(value: string) {
if (hasHostBridgeControlCharacter(value)) {