补齐移动壳文件能力失败观测
移动壳文件系统能力记录原生异常日志 文件能力测试覆盖稳定错误和日志边界 配置门禁和文档同步文件失败观测约束
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 桌面壳不声明拍摄能力,普通浏览器、小程序和未声明能力的裁剪壳不显示该入口。
|
||||
|
||||
Reference in New Issue
Block a user