收口移动文件导出响应边界
将 Expo 文本、图片和音频导出成功响应包装收口到 files 模块 让移动 dispatch 只委托文件导出请求 同步移动壳配置门禁和共享记忆
This commit is contained in:
@@ -1676,32 +1676,41 @@ assertMobileDocumentPickerBoundary(
|
||||
'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES',
|
||||
);
|
||||
|
||||
for (const [wrapperName, importCall] of [
|
||||
for (const [wrapperName, fileCall] of [
|
||||
['exportMobileHostBridgeTextFile', 'ok(request, await exportTextFile(request.payload))'],
|
||||
['importMobileHostBridgeTextFile', 'ok(request, await importTextFile())'],
|
||||
['importMobileHostBridgeDocumentFile', 'ok(request, await importDocumentFile())'],
|
||||
['exportMobileHostBridgeImageFile', 'ok(request, await exportImageFile(request.payload))'],
|
||||
['importMobileHostBridgeImageFile', 'ok(request, await importImageFile())'],
|
||||
['captureMobileHostBridgeImageFile', 'ok(request, await captureImageFile())'],
|
||||
['exportMobileHostBridgeAudioFile', 'ok(request, await exportAudioFile(request.payload))'],
|
||||
['importMobileHostBridgeAudioFile', 'ok(request, await importAudioFile())'],
|
||||
]) {
|
||||
const wrapperBody = extractFunctionBody(filesSource, wrapperName);
|
||||
if (!wrapperBody.includes(importCall)) {
|
||||
throw new Error(`mobile shell file import HostBridge wrapper missing ${importCall}`);
|
||||
if (!wrapperBody.includes(fileCall)) {
|
||||
throw new Error(`mobile shell file HostBridge wrapper missing ${fileCall}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!dispatchSource.includes('exportMobileHostBridgeTextFile(request)') ||
|
||||
!dispatchSource.includes('importMobileHostBridgeTextFile(request)') ||
|
||||
!dispatchSource.includes('importMobileHostBridgeDocumentFile(request)') ||
|
||||
!dispatchSource.includes('exportMobileHostBridgeImageFile(request)') ||
|
||||
!dispatchSource.includes('importMobileHostBridgeImageFile(request)') ||
|
||||
!dispatchSource.includes('captureMobileHostBridgeImageFile(request)') ||
|
||||
!dispatchSource.includes('exportMobileHostBridgeAudioFile(request)') ||
|
||||
!dispatchSource.includes('importMobileHostBridgeAudioFile(request)') ||
|
||||
dispatchSource.includes('ok(request, await exportTextFile(request.payload))') ||
|
||||
dispatchSource.includes('ok(request, await importTextFile())') ||
|
||||
dispatchSource.includes('ok(request, await importDocumentFile())') ||
|
||||
dispatchSource.includes('ok(request, await exportImageFile(request.payload))') ||
|
||||
dispatchSource.includes('ok(request, await importImageFile())') ||
|
||||
dispatchSource.includes('ok(request, await captureImageFile())') ||
|
||||
dispatchSource.includes('ok(request, await exportAudioFile(request.payload))') ||
|
||||
dispatchSource.includes('ok(request, await importAudioFile())')
|
||||
) {
|
||||
throw new Error('mobile shell dispatch must delegate file imports to files.ts');
|
||||
throw new Error('mobile shell dispatch must delegate file requests to files.ts');
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type HostBridgeRequest } from '../../../../packages/shared/src/contracts/hostBridge';
|
||||
import {
|
||||
captureMobileHostBridgeImageFile,
|
||||
exportAudioFile,
|
||||
exportImageFile,
|
||||
exportTextFile,
|
||||
exportMobileHostBridgeAudioFile,
|
||||
exportMobileHostBridgeImageFile,
|
||||
exportMobileHostBridgeTextFile,
|
||||
importMobileHostBridgeAudioFile,
|
||||
importMobileHostBridgeDocumentFile,
|
||||
importMobileHostBridgeImageFile,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {
|
||||
failure,
|
||||
type MobileHostBridgeNavigation,
|
||||
ok,
|
||||
unsupported,
|
||||
} from './protocol';
|
||||
import { resetQrScannerForTest, scanMobileHostBridgeQrCode } from './scanner';
|
||||
@@ -64,13 +63,13 @@ export async function dispatchMobileHostBridgeRequest(
|
||||
case 'clipboard.readText':
|
||||
return readMobileHostBridgeClipboardText(request);
|
||||
case 'file.exportText':
|
||||
return ok(request, await exportTextFile(request.payload));
|
||||
return exportMobileHostBridgeTextFile(request);
|
||||
case 'file.importText':
|
||||
return importMobileHostBridgeTextFile(request);
|
||||
case 'file.importDocument':
|
||||
return importMobileHostBridgeDocumentFile(request);
|
||||
case 'file.exportImage':
|
||||
return ok(request, await exportImageFile(request.payload));
|
||||
return exportMobileHostBridgeImageFile(request);
|
||||
case 'file.importImage':
|
||||
return importMobileHostBridgeImageFile(request);
|
||||
case 'file.captureImage':
|
||||
@@ -80,7 +79,7 @@ export async function dispatchMobileHostBridgeRequest(
|
||||
case 'file.importAudio':
|
||||
return importMobileHostBridgeAudioFile(request);
|
||||
case 'file.exportAudio':
|
||||
return ok(request, await exportAudioFile(request.payload));
|
||||
return exportMobileHostBridgeAudioFile(request);
|
||||
case 'haptics.impact':
|
||||
return runMobileHostBridgeHapticsImpact(request);
|
||||
case 'notification.showLocal':
|
||||
|
||||
@@ -285,6 +285,12 @@ export async function exportTextFile(
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportMobileHostBridgeTextFile(
|
||||
request: HostBridgeRequest,
|
||||
) {
|
||||
return ok(request, await exportTextFile(request.payload));
|
||||
}
|
||||
|
||||
function normalizeImportedTextMimeType(
|
||||
value: unknown,
|
||||
fileName: string,
|
||||
@@ -528,6 +534,12 @@ export async function exportImageFile(
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportMobileHostBridgeImageFile(
|
||||
request: HostBridgeRequest,
|
||||
) {
|
||||
return ok(request, await exportImageFile(request.payload));
|
||||
}
|
||||
|
||||
function normalizeImportedImageMimeType(
|
||||
value: unknown,
|
||||
): HostBridgeImageMimeType | null {
|
||||
@@ -760,6 +772,12 @@ export async function exportAudioFile(
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportMobileHostBridgeAudioFile(
|
||||
request: HostBridgeRequest,
|
||||
) {
|
||||
return ok(request, await exportAudioFile(request.payload));
|
||||
}
|
||||
|
||||
export async function importAudioFile(): Promise<FileImportAudioResult> {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
copyToCacheDirectory: true,
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
- 2026-06-18 登录 / 支付能力禁伪声明:`auth.requestLogin` 和 `payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo / Tauri 壳在真实 SDK、渠道流程和后端契约落地前不得声明这些 capability,也不得把它们写入入口 URL `hostCapabilities`;两端检查脚本会拒绝伪声明,请求实际到达壳层时必须返回明确 `unsupported_method` 并让 H5 fallback,两端壳测试直接覆盖这两个 method。
|
||||
- 2026-06-18 移动壳触觉反馈边界:`haptics.impact` 只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`;未知值必须返回 `invalid_request`,不得静默降级成真实设备触觉反馈。桌面壳不声明该 capability,H5 继续按 HostBridge fallback 处理。
|
||||
- 2026-06-19 移动壳触觉反馈模块边界:Expo `haptics.impact` 的 HostBridge payload 解析、共享 style 归一、`light` / `medium` / `heavy` 到 `Haptics.ImpactFeedbackStyle` 的映射、真实 `Haptics.impactAsync(...)` 调用和成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/haptics.ts`;`dispatch.ts` 只负责把完整 request 委托给 `runMobileHostBridgeHapticsImpact(...)`,不得直接导入 `expo-haptics`、读取 `HapticsImpactPayload`、调用 `Haptics.impactAsync` 或包装触觉反馈成功响应。移动壳配置检查会覆盖该模块结构、共享 style 边界和 dispatch 委托关系,避免触觉反馈能力散落到分发层。
|
||||
- 2026-06-18 分享卡图片导出:新增 `file.exportImage` HostBridge capability,H5 分享卡下载在 native app 中优先把 canvas 生成的 base64 图片交给宿主导出;Expo 壳写缓存图片后交给系统分享 / 保存面板,Tauri 壳通过系统保存对话框写入图片字节。该能力只接受 `image/png` / `image/jpeg` / `image/webp`、单次 5 MiB 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。
|
||||
- 2026-06-18 分享卡图片导出:新增 `file.exportImage` HostBridge capability,H5 分享卡下载在 native app 中优先把 canvas 生成的 base64 图片交给宿主导出;Expo 壳写缓存图片后交给系统分享 / 保存面板,Tauri 壳通过系统保存对话框写入图片字节。该能力只接受 `image/png` / `image/jpeg` / `image/webp`、单次 5 MiB 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。Expo 图片导出的 payload 校验、缓存写入、系统分享 / 保存面板和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。
|
||||
- 2026-06-18 应用角标能力:新增 `app.setBadgeCount` HostBridge capability,H5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数并在宿主未声明时静默 fallback;Expo 壳只在 iOS 声明并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明、不伪造成功;Tauri 壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回真实错误。
|
||||
- 2026-06-18 草稿生成未读角标:平台壳层把“可见作品架里未读的草稿生成完成更新”同步到 `app.setBadgeCount`;同一草稿的 work/profile/session 等多个恢复 ID 只计 1,已读、失败、生成中和不可见草稿不计入。该角标只消费已有 HostBridge 能力,宿主不支持或设置失败不影响 H5 红点、作品架或后端状态。
|
||||
- 2026-06-19 原生壳角标边界:Expo `app.setBadgeCount` 的 iOS 平台判定、共享上限校验、`PushNotificationIOS.setApplicationIconBadgeNumber(...)` 调用和 HostBridge 成功 / 失败响应映射统一收口在 `apps/mobile-shell/src/host-bridge/badge.ts`;Tauri `app.setBadgeCount` 的 payload 校验、清除语义、主窗口 `set_badge_count` 调用和 HostBridge 响应映射统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`。两端 `dispatch` 只负责委托对应 badge 模块,配置检查会拒绝分发层直接导入角标底层 API、重声明数量边界或包装角标成功响应。
|
||||
@@ -2390,7 +2390,7 @@
|
||||
## 2026-06-18 原生壳音频导出只写入 H5 已持有字节
|
||||
|
||||
- 背景:木鱼创作的本地录音 / 上传音频会在浏览器侧处理成 `Blob`,原生壳需要能把这份本地处理结果交给系统保存 / 分享;但宿主不能替 H5 读取任意本地音频文件,也不能把文件系统能力扩成通用读写。
|
||||
- 决策:新增 HostBridge method `file.exportAudio`、H5 facade `exportHostAudioFile()` 和通用音频输入面板导出入口。H5 只传当前页面已持有的 `base64Data`、清洗后的文件名和允许的 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` MIME;移动端写入 Expo 缓存音频后交给系统分享 / 保存面板,桌面端打开 Tauri 系统保存对话框并写入音频字节。单次不超过 20 MiB,成功只返回文件名和字节数。`CreativeAudioInputPanel` 只在当前资产包含本地 `Blob`、`fileName`、允许 MIME 且宿主声明 `file.exportAudio` 时显示导出入口;远端已上传音频不展示导出。
|
||||
- 决策:新增 HostBridge method `file.exportAudio`、H5 facade `exportHostAudioFile()` 和通用音频输入面板导出入口。H5 只传当前页面已持有的 `base64Data`、清洗后的文件名和允许的 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` MIME;移动端写入 Expo 缓存音频后交给系统分享 / 保存面板,桌面端打开 Tauri 系统保存对话框并写入音频字节。单次不超过 20 MiB,成功只返回文件名和字节数。Expo 音频导出的 payload 校验、缓存写入、系统分享 / 保存面板和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。`CreativeAudioInputPanel` 只在当前资产包含本地 `Blob`、`fileName`、允许 MIME 且宿主声明 `file.exportAudio` 时显示导出入口;远端已上传音频不展示导出。
|
||||
- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`src/components/common/CreativeAudioInputPanel.tsx`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。
|
||||
- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -2404,7 +2404,7 @@
|
||||
## 2026-06-19 创作 Agent 会话导出接入原生壳文本导出
|
||||
|
||||
- 背景:Expo / Tauri 壳已经实现 `file.exportText`,但 H5 创作 Agent 工作台还没有消费该能力;原生壳内又禁止网页自动下载和 `<a download>` 直接落盘,需要通过受控 HostBridge 保存用户可带走的文本。
|
||||
- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.exportText` 时显示“导出会话”图标按钮,把当前会话标题、摘要、进度、锚点、消息、流式回复和输入草稿组装为 `text/markdown`,并在 H5 侧按共享 5 MiB 上限计算 UTF-8 byte 后再调用 `exportHostTextFile()`。普通浏览器、小程序、旧壳或裁剪壳不展示该入口;宿主取消或 unsupported 不触发浏览器下载回退,错误统一显示在 composer 上方现有状态条。
|
||||
- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.exportText` 时显示“导出会话”图标按钮,把当前会话标题、摘要、进度、锚点、消息、流式回复和输入草稿组装为 `text/markdown`,并在 H5 侧按共享 5 MiB 上限计算 UTF-8 byte 后再调用 `exportHostTextFile()`。Expo 文本导出的 payload 校验、缓存写入、系统分享 / 保存面板和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。普通浏览器、小程序、旧壳或裁剪壳不展示该入口;宿主取消或 unsupported 不触发浏览器下载回退,错误统一显示在 composer 上方现有状态条。
|
||||
- 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx`、`src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。
|
||||
- 验证方式:`npm run check:native-shells`、`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user