补齐移动壳文件能力失败观测

移动壳文件系统能力记录原生异常日志

文件能力测试覆盖稳定错误和日志边界

配置门禁和文档同步文件失败观测约束
This commit is contained in:
2026-06-20 19:15:15 +08:00
parent c5d899480f
commit 5389a43bfe
5 changed files with 124 additions and 16 deletions
@@ -2083,6 +2083,17 @@ for (const snippet of [
'Sharing.shareAsync',
'assertMobileFileSharingAvailable',
'shareMobileFile',
'logMobileHostBridgeFileFailure',
'mobile HostBridge file failed for',
"logMobileHostBridgeFileFailure('sharing.available', error)",
"logMobileHostBridgeFileFailure('sharing.open', error)",
"logMobileHostBridgeFileFailure('document_picker.open', error)",
"logMobileHostBridgeFileFailure('file.read_text', error)",
"logMobileHostBridgeFileFailure('file.read_base64', error)",
"logMobileHostBridgeFileFailure('image.library_permission', error)",
"logMobileHostBridgeFileFailure('image.library_open', error)",
"logMobileHostBridgeFileFailure('image.camera_permission', error)",
"logMobileHostBridgeFileFailure('image.camera_open', error)",
'pickMobileDocumentFile',
'readMobileTextFile',
'readMobileBase64File',
@@ -2254,6 +2265,15 @@ for (const snippet of [
'maps native image library launch failures to stable import errors',
'rejects image capture before camera launch when camera permission request fails',
'maps native camera launch failures to stable capture errors',
'mobile HostBridge file failed for sharing.available',
'mobile HostBridge file failed for sharing.open',
'mobile HostBridge file failed for document_picker.open',
'mobile HostBridge file failed for file.read_text',
'mobile HostBridge file failed for file.read_base64',
'mobile HostBridge file failed for image.library_permission',
'mobile HostBridge file failed for image.library_open',
'mobile HostBridge file failed for image.camera_permission',
'mobile HostBridge file failed for image.camera_open',
"message: 'photo library permission denied'",
"message: 'photo library permission unavailable'",
"message: 'photo library unavailable'",
@@ -203,7 +203,9 @@ describe('mobile HostBridge file actions', () => {
});
test('maps native sharing availability failures to stable export errors', async () => {
shareAvailableMock.mockRejectedValueOnce(new Error('expo sharing crashed'));
const error = new Error('expo sharing crashed');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
shareAvailableMock.mockRejectedValueOnce(error);
await expect(
exportTextFile({
@@ -218,10 +220,17 @@ describe('mobile HostBridge file actions', () => {
expect(writtenFiles).toHaveLength(0);
expect(shareAsyncMock).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for sharing.available',
error,
);
warnSpy.mockRestore();
});
test('maps native sharing sheet failures to stable export errors', async () => {
shareAsyncMock.mockRejectedValueOnce(new Error('expo native share failed'));
const error = new Error('expo native share failed');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
shareAsyncMock.mockRejectedValueOnce(error);
await expect(
exportImageFile({
@@ -241,6 +250,11 @@ describe('mobile HostBridge file actions', () => {
options: { encoding: 'base64' },
},
]);
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for sharing.open',
error,
);
warnSpy.mockRestore();
});
test('imports text and document files without exposing local URIs', async () => {
@@ -305,15 +319,23 @@ describe('mobile HostBridge file actions', () => {
});
test('maps native document picker failures to stable import errors', async () => {
documentPickerMock.mockRejectedValueOnce(new Error('native text picker failed'));
const error = new Error('native text picker failed');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
documentPickerMock.mockRejectedValueOnce(error);
await expect(importTextFile()).rejects.toMatchObject({
code: 'host_error',
message: 'text file picker unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for document_picker.open',
error,
);
warnSpy.mockRestore();
});
test('maps native text file read failures to stable import errors', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
failingTextFiles.add('file:///picked/story.md');
fileSizes.set('file:///picked/story.md', Buffer.byteLength('故事'));
documentPickerMock.mockResolvedValueOnce({
@@ -333,9 +355,15 @@ describe('mobile HostBridge file actions', () => {
code: 'host_error',
message: 'text file unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for file.read_text',
expect.any(Error),
);
warnSpy.mockRestore();
});
test('maps native binary file read failures to stable import errors', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
failingBase64Files.add('file:///picked/brief.docx');
fileSizes.set('file:///picked/brief.docx', Buffer.byteLength('PK\x03\x04docx'));
documentPickerMock.mockResolvedValueOnce({
@@ -356,9 +384,15 @@ describe('mobile HostBridge file actions', () => {
code: 'host_error',
message: 'document file unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for file.read_base64',
expect.any(Error),
);
warnSpy.mockRestore();
});
test('maps native audio file read failures to stable import errors', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
failingBase64Files.add('file:///picked/voice.mp3');
fileSizes.set('file:///picked/voice.mp3', Buffer.byteLength('ID3\x04\x00\x00\x00\x00\x00\x10'));
documentPickerMock.mockResolvedValueOnce({
@@ -378,6 +412,11 @@ describe('mobile HostBridge file actions', () => {
code: 'host_error',
message: 'audio file unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for file.read_base64',
expect.any(Error),
);
warnSpy.mockRestore();
});
test('imports and captures images only after native permissions are granted', async () => {
@@ -448,53 +487,81 @@ describe('mobile HostBridge file actions', () => {
});
test('rejects image import before picker launch when library permission request fails', async () => {
libraryPermissionMock.mockRejectedValueOnce(new Error('native permission failed'));
const error = new Error('native permission failed');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
libraryPermissionMock.mockRejectedValueOnce(error);
await expect(importImageFile()).rejects.toMatchObject({
code: 'host_error',
message: 'photo library permission unavailable',
});
expect(imageLibraryMock).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for image.library_permission',
error,
);
warnSpy.mockRestore();
});
test('maps native image library launch failures to stable import errors', async () => {
const error = new Error('native image picker failed');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
libraryPermissionMock.mockResolvedValueOnce({
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
});
imageLibraryMock.mockRejectedValueOnce(new Error('native image picker failed'));
imageLibraryMock.mockRejectedValueOnce(error);
await expect(importImageFile()).rejects.toMatchObject({
code: 'host_error',
message: 'photo library unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for image.library_open',
error,
);
warnSpy.mockRestore();
});
test('rejects image capture before camera launch when camera permission request fails', async () => {
cameraPermissionMock.mockRejectedValueOnce(new Error('native permission failed'));
const error = new Error('native permission failed');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
cameraPermissionMock.mockRejectedValueOnce(error);
await expect(captureImageFile()).rejects.toMatchObject({
code: 'host_error',
message: 'camera permission unavailable',
});
expect(cameraMock).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for image.camera_permission',
error,
);
warnSpy.mockRestore();
});
test('maps native camera launch failures to stable capture errors', async () => {
const error = new Error('native camera failed');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
cameraPermissionMock.mockResolvedValueOnce({
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
});
cameraMock.mockRejectedValueOnce(new Error('native camera failed'));
cameraMock.mockRejectedValueOnce(error);
await expect(captureImageFile()).rejects.toMatchObject({
code: 'host_error',
message: 'camera unavailable',
});
expect(warnSpy).toHaveBeenCalledWith(
'mobile HostBridge file failed for image.camera_open',
error,
);
warnSpy.mockRestore();
});
test('imports audio and exports binary files through controlled payloads', async () => {
+22 -9
View File
@@ -51,6 +51,10 @@ import {
} from './filePayloads';
import { invalidRequest, ok } from './protocol';
function logMobileHostBridgeFileFailure(label: string, error: unknown) {
console.warn(`mobile HostBridge file failed for ${label}`, error);
}
type MobileDocumentPickerOptions = Parameters<
typeof DocumentPicker.getDocumentAsync
>[0];
@@ -62,7 +66,8 @@ async function assertMobileFileSharingAvailable() {
let isSharingAvailable = false;
try {
isSharingAvailable = await Sharing.isAvailableAsync();
} catch {
} catch (error) {
logMobileHostBridgeFileFailure('sharing.available', error);
throw {
code: 'host_error',
message: 'file sharing unavailable',
@@ -87,7 +92,8 @@ async function shareMobileFile(
) {
try {
await Sharing.shareAsync(file.uri, options);
} catch {
} catch (error) {
logMobileHostBridgeFileFailure('sharing.open', error);
throw {
code: 'host_error',
message: 'file sharing failed',
@@ -334,7 +340,8 @@ export async function importImageFile(): Promise<FileImportImageResult> {
let permission: ImagePicker.MediaLibraryPermissionResponse;
try {
permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
} catch {
} catch (error) {
logMobileHostBridgeFileFailure('image.library_permission', error);
throw {
code: 'host_error',
message: 'photo library permission unavailable',
@@ -357,7 +364,8 @@ export async function importImageFile(): Promise<FileImportImageResult> {
mediaTypes: ['images'],
quality: 1,
});
} catch {
} catch (error) {
logMobileHostBridgeFileFailure('image.library_open', error);
throw {
code: 'host_error',
message: 'photo library unavailable',
@@ -381,7 +389,8 @@ export async function captureImageFile(): Promise<FileImportImageResult> {
let permission: ImagePicker.CameraPermissionResponse;
try {
permission = await ImagePicker.requestCameraPermissionsAsync();
} catch {
} catch (error) {
logMobileHostBridgeFileFailure('image.camera_permission', error);
throw {
code: 'host_error',
message: 'camera permission unavailable',
@@ -403,7 +412,8 @@ export async function captureImageFile(): Promise<FileImportImageResult> {
mediaTypes: ['images'],
quality: 1,
});
} catch {
} catch (error) {
logMobileHostBridgeFileFailure('image.camera_open', error);
throw {
code: 'host_error',
message: 'camera unavailable',
@@ -562,7 +572,8 @@ async function pickMobileDocumentFile(
): Promise<MobileDocumentPickerResult> {
try {
return await DocumentPicker.getDocumentAsync(options);
} catch {
} catch (error) {
logMobileHostBridgeFileFailure('document_picker.open', error);
throw {
code: 'host_error',
message: unavailableMessage,
@@ -573,7 +584,8 @@ async function pickMobileDocumentFile(
async function readMobileTextFile(file: File, unavailableMessage: string) {
try {
return await file.text();
} catch {
} catch (error) {
logMobileHostBridgeFileFailure('file.read_text', error);
throw {
code: 'host_error',
message: unavailableMessage,
@@ -584,7 +596,8 @@ async function readMobileTextFile(file: File, unavailableMessage: string) {
async function readMobileBase64File(file: File, unavailableMessage: string) {
try {
return await file.base64();
} catch {
} catch (error) {
logMobileHostBridgeFileFailure('file.read_base64', error);
throw {
code: 'host_error',
message: unavailableMessage,
@@ -3100,6 +3100,12 @@
- 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/network.rs``apps/desktop-shell/scripts/check-config.mjs``scripts/check-native-shells.mjs`、宿主壳方案文档、宿主壳能力统一协议文档。
- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml host_bridge::network shell::network``npm run desktop-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 移动壳文件能力系统异常必须可观测
- 背景:Expo 移动壳已声明文本 / 文档 / 图片 / 音频导入导出、拍照和相册能力;这些能力会打开系统分享面板、DocumentPicker、相册、相机或读取缓存文件。如果原生 API reject 后只返回稳定 HostBridge 错误,H5 语义是安全的,但开发侧难以区分系统能力缺失、权限 API 异常、文件读取失败或分享面板失败。
- 决策:`apps/mobile-shell/src/host-bridge/files.ts` 必须在 Expo Sharing 可用性 / 分享面板、DocumentPicker、文本 / base64 文件读取、相册 / 相机权限请求和相册 / 相机打开失败时记录 `mobile HostBridge file failed for ...` 日志;HostBridge 对 H5 仍只返回稳定 `host_error` / `unsupported_capability` / `cancelled` / `invalid_request` 语义,不透传原生异常明细。移动壳配置检查反查日志 helper、关键 label 和对应单测。
- 验证方式:`npm run mobile-shell:test -- src/host-bridge/files.test.ts``npm run mobile-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 移动壳门禁脚本必须自登记自扫描
- 背景:Expo 移动壳单端检查已把 `apps/mobile-shell/scripts/` 纳入生产源码扫描入口,但 `check-config.mjs` 自身仍被排除在脚本清单和替身词扫描之外;这会让移动壳与桌面壳门禁结构不一致,也可能让后续门禁反查内容绕过生产替身词规则。
@@ -318,6 +318,8 @@ GameBridge 禁止:
2026-06-19 追加:H5 创作 Agent 工作台在移动壳声明 `file.exportText` 时提供会话 Markdown 导出入口。H5 只把当前会话标题、摘要、进度、锚点、消息、流式回复和输入草稿组装成 `text/markdown` 文本,先按共享 5 MiB 上限计算 UTF-8 byte,再通过 `exportHostTextFile()` 交给 Expo 系统分享 / 保存面板;宿主取消、缺能力或 unsupported 时不做浏览器下载回退,保持原生壳文件保存只走受控 HostBridge 能力。
2026-06-20 追加:移动壳文件能力的原生系统异常必须可观测。Expo Sharing 可用性 / 分享面板、DocumentPicker、文本 / base64 文件读取、相册 / 相机权限请求和相册 / 相机打开失败时,`apps/mobile-shell/src/host-bridge/files.ts` 必须记录 `mobile HostBridge file failed for ...` 日志;HostBridge 回包仍只返回稳定的 `host_error` / `unsupported_capability` / `cancelled` / `invalid_request` 语义,不把原生异常明细透传给 H5。移动壳配置检查反查这些日志 label 和对应单测,避免用户可见文件导入导出动作失败后只剩静默兜底。
2026-06-19 追加:视觉小说结果页素材选择弹窗在移动壳声明 `file.importImage` / `file.importAudio` 时优先打开宿主系统图片或音频选择器,把 HostBridge 返回的 base64 副本转换成浏览器 `File` 后继续调用 `uploadVisualNovelAsset`,不绕过平台素材上传、历史素材、AI 图片生成或草稿写回链路;用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入。普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
2026-06-19 追加:帮助与反馈页在移动壳声明 `file.captureImage` 时展示“拍摄凭证”入口,调用 Expo 相机拍摄后把 HostBridge 返回的图片副本转换成浏览器 `File`,继续复用反馈页最多 4 张、单张 1 MiB、总 4 MiB、图片 MIME、data URL 预览和提交 payload 校验。拍摄取消时停留在壳流程内,不触发浏览器文件输入;Tauri 桌面壳不声明拍摄能力,普通浏览器、小程序和未声明能力的裁剪壳不显示该入口。