移动壳校验文件真实内容
为移动图片和音频导入导出增加 bytes 头部校验 补充移动 HostBridge 文件能力测试与配置门禁 更新宿主壳文件能力文档
This commit is contained in:
@@ -922,6 +922,14 @@ for (const forbiddenNotificationSnippet of [
|
||||
}
|
||||
}
|
||||
|
||||
for (const forbiddenHostBridgeRuntimeSnippet of ['atob(', 'Buffer.from']) {
|
||||
if (hostBridgeSource.includes(forbiddenHostBridgeRuntimeSnippet)) {
|
||||
throw new Error(
|
||||
`mobile shell HostBridge production code must not rely on ${forbiddenHostBridgeRuntimeSnippet}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test(
|
||||
dispatchSource,
|
||||
@@ -956,6 +964,12 @@ for (const snippet of [
|
||||
'ImagePicker.requestCameraPermissionsAsync',
|
||||
'File(asset.uri)',
|
||||
'file.base64()',
|
||||
'detectImageMimeType',
|
||||
'detectAudioMimeType',
|
||||
'ensureImageBytesMatchMimeType',
|
||||
'ensureAudioBytesMatchMimeType',
|
||||
"'image bytes do not match MIME'",
|
||||
"'audio bytes do not match MIME'",
|
||||
'normalizeHostBridgeExportFileName',
|
||||
'normalizeHostBridgeClipboardText',
|
||||
'normalizeHostBridgeHapticsImpactStyle',
|
||||
|
||||
@@ -53,6 +53,22 @@ const TEST_MOBILE_URL_OPTIONS: MobileShellUrlOptions = {
|
||||
capabilities: ['host.getRuntime', 'share.open'],
|
||||
};
|
||||
|
||||
function encodeBytes(bytes: readonly number[]) {
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
const PNG_BASE64 = encodeBytes([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0,
|
||||
]);
|
||||
const JPEG_BASE64 = encodeBytes([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0]);
|
||||
const WAV_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WAVE', 'binary').toString(
|
||||
'base64',
|
||||
);
|
||||
const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString(
|
||||
'base64',
|
||||
);
|
||||
const WEBM_BASE64 = encodeBytes([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]);
|
||||
|
||||
vi.mock('expo-clipboard', () => ({
|
||||
getStringAsync: vi.fn(),
|
||||
setStringAsync: vi.fn(),
|
||||
@@ -928,7 +944,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
const response = await send(
|
||||
request('file.exportImage', {
|
||||
fileName: ' ../分享:卡?.png ',
|
||||
base64Data: 'c2hhcmUtY2FyZA==',
|
||||
base64Data: PNG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
}),
|
||||
);
|
||||
@@ -938,12 +954,12 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(okResponse.result).toEqual({
|
||||
action: 'saved',
|
||||
fileName: '分享-卡-.png',
|
||||
bytes: 10,
|
||||
bytes: 12,
|
||||
});
|
||||
expect(writtenFiles).toEqual([
|
||||
{
|
||||
uri: 'file:///cache/分享-卡-.png',
|
||||
content: 'c2hhcmUtY2FyZA==',
|
||||
content: PNG_BASE64,
|
||||
options: { encoding: 'base64' },
|
||||
},
|
||||
]);
|
||||
@@ -961,7 +977,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
const unsupportedMime = await send(
|
||||
request('file.exportImage', {
|
||||
fileName: '分享卡.txt',
|
||||
base64Data: 'c2hhcmUtY2FyZA==',
|
||||
base64Data: PNG_BASE64,
|
||||
mimeType: 'text/plain',
|
||||
}),
|
||||
);
|
||||
@@ -977,6 +993,18 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
);
|
||||
|
||||
expect(expectFailed(oversized).error.code).toBe('invalid_request');
|
||||
|
||||
const mismatched = await send(
|
||||
request('file.exportImage', {
|
||||
fileName: '分享卡.png',
|
||||
base64Data: JPEG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(expectFailed(mismatched).error.message).toBe(
|
||||
'image bytes do not match MIME',
|
||||
);
|
||||
expect(writtenFiles).toEqual([]);
|
||||
expect(Sharing.shareAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -985,7 +1013,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
const response = await send(
|
||||
request('file.exportAudio', {
|
||||
fileName: ' ../敲击:音效?.wav ',
|
||||
base64Data: 'YXVkaW8=',
|
||||
base64Data: WAV_BASE64,
|
||||
mimeType: 'audio/wav',
|
||||
}),
|
||||
);
|
||||
@@ -995,12 +1023,12 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(okResponse.result).toEqual({
|
||||
action: 'saved',
|
||||
fileName: '敲击-音效-.wav',
|
||||
bytes: 5,
|
||||
bytes: 12,
|
||||
});
|
||||
expect(writtenFiles).toEqual([
|
||||
{
|
||||
uri: 'file:///cache/敲击-音效-.wav',
|
||||
content: 'YXVkaW8=',
|
||||
content: WAV_BASE64,
|
||||
options: { encoding: 'base64' },
|
||||
},
|
||||
]);
|
||||
@@ -1020,7 +1048,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
const response = await send(
|
||||
request('file.exportAudio', {
|
||||
fileName: 'hit.wav',
|
||||
base64Data: 'YXVkaW8=',
|
||||
base64Data: WAV_BASE64,
|
||||
mimeType: 'audio/wav',
|
||||
}),
|
||||
);
|
||||
@@ -1036,7 +1064,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
const unsupportedMime = await send(
|
||||
request('file.exportAudio', {
|
||||
fileName: 'hit.txt',
|
||||
base64Data: 'YXVkaW8=',
|
||||
base64Data: WAV_BASE64,
|
||||
mimeType: 'text/plain',
|
||||
}),
|
||||
);
|
||||
@@ -1059,6 +1087,17 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
}),
|
||||
);
|
||||
expect(expectFailed(oversized).error.code).toBe('invalid_request');
|
||||
|
||||
const mismatched = await send(
|
||||
request('file.exportAudio', {
|
||||
fileName: 'hit.wav',
|
||||
base64Data: MP3_BASE64,
|
||||
mimeType: 'audio/wav',
|
||||
}),
|
||||
);
|
||||
expect(expectFailed(mismatched).error.message).toBe(
|
||||
'audio bytes do not match MIME',
|
||||
);
|
||||
expect(writtenFiles).toEqual([]);
|
||||
expect(Sharing.shareAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1073,8 +1112,8 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
height: 80,
|
||||
type: 'image',
|
||||
fileName: ' ../参考:图?.png ',
|
||||
fileSize: 5,
|
||||
base64: 'aW1hZ2U=',
|
||||
fileSize: 12,
|
||||
base64: PNG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
@@ -1085,9 +1124,9 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(expectOk(response).result).toEqual({
|
||||
action: 'selected',
|
||||
fileName: '参考-图-.png',
|
||||
base64Data: 'aW1hZ2U=',
|
||||
base64Data: PNG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
bytes: 5,
|
||||
bytes: 12,
|
||||
});
|
||||
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalled();
|
||||
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
|
||||
@@ -1145,7 +1184,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
type: 'image',
|
||||
fileName: '参考图.gif',
|
||||
fileSize: 5,
|
||||
base64: 'aW1hZ2U=',
|
||||
base64: PNG_BASE64,
|
||||
mimeType: 'image/gif',
|
||||
},
|
||||
],
|
||||
@@ -1165,7 +1204,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
type: 'image',
|
||||
fileName: '参考图.png',
|
||||
fileSize: 10 * 1024 * 1024 + 1,
|
||||
base64: 'aW1hZ2U=',
|
||||
base64: PNG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
@@ -1174,6 +1213,28 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
const oversized = await send(request('file.importImage'));
|
||||
|
||||
expect(expectFailed(oversized).error.code).toBe('invalid_request');
|
||||
|
||||
vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///private/mobile/参考图.png',
|
||||
width: 120,
|
||||
height: 80,
|
||||
type: 'image',
|
||||
fileName: '参考图.png',
|
||||
fileSize: 7,
|
||||
base64: JPEG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mismatched = await send(request('file.importImage'));
|
||||
|
||||
expect(expectFailed(mismatched).error.message).toBe(
|
||||
'image bytes do not match MIME',
|
||||
);
|
||||
});
|
||||
|
||||
test('file.captureImage 调起系统相机并返回受控图片数据', async () => {
|
||||
@@ -1186,8 +1247,8 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
height: 80,
|
||||
type: 'image',
|
||||
fileName: null,
|
||||
fileSize: 6,
|
||||
base64: 'Y2FtZXJh',
|
||||
fileSize: 7,
|
||||
base64: JPEG_BASE64,
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
],
|
||||
@@ -1198,9 +1259,9 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(expectOk(response).result).toEqual({
|
||||
action: 'captured',
|
||||
fileName: 'genarrative-import.jpg',
|
||||
base64Data: 'Y2FtZXJh',
|
||||
base64Data: JPEG_BASE64,
|
||||
mimeType: 'image/jpeg',
|
||||
bytes: 6,
|
||||
bytes: 7,
|
||||
});
|
||||
expect(ImagePicker.requestCameraPermissionsAsync).toHaveBeenCalled();
|
||||
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({
|
||||
@@ -1242,7 +1303,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
});
|
||||
|
||||
test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => {
|
||||
fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8=');
|
||||
fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64);
|
||||
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [
|
||||
@@ -1250,7 +1311,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
uri: 'file:///private/mobile/hit.webm',
|
||||
name: ' ../敲击:音效?.webm ',
|
||||
mimeType: 'audio/webm',
|
||||
size: 5,
|
||||
size: 6,
|
||||
lastModified: 1,
|
||||
},
|
||||
],
|
||||
@@ -1261,9 +1322,9 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(expectOk(response).result).toEqual({
|
||||
action: 'selected',
|
||||
fileName: '敲击-音效-.webm',
|
||||
base64Data: 'YXVkaW8=',
|
||||
base64Data: WEBM_BASE64,
|
||||
mimeType: 'audio/webm',
|
||||
bytes: 5,
|
||||
bytes: 6,
|
||||
});
|
||||
expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({
|
||||
copyToCacheDirectory: true,
|
||||
@@ -1288,7 +1349,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
|
||||
expect(expectFailed(cancelled).error.code).toBe('cancelled');
|
||||
|
||||
fileBase64Data.set('file:///private/mobile/hit.txt', 'YXVkaW8=');
|
||||
fileBase64Data.set('file:///private/mobile/hit.txt', WEBM_BASE64);
|
||||
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [
|
||||
@@ -1306,7 +1367,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
|
||||
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
|
||||
|
||||
fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8=');
|
||||
fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64);
|
||||
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [
|
||||
@@ -1323,5 +1384,25 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
const oversized = await send(request('file.importAudio'));
|
||||
|
||||
expect(expectFailed(oversized).error.code).toBe('invalid_request');
|
||||
|
||||
fileBase64Data.set('file:///private/mobile/hit.webm', MP3_BASE64);
|
||||
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [
|
||||
{
|
||||
uri: 'file:///private/mobile/hit.webm',
|
||||
name: 'hit.webm',
|
||||
mimeType: 'audio/webm',
|
||||
size: 10,
|
||||
lastModified: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mismatched = await send(request('file.importAudio'));
|
||||
|
||||
expect(expectFailed(mismatched).error.message).toBe(
|
||||
'audio bytes do not match MIME',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,8 @@ const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set<HostBridgeAudioMimeType>([
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
]);
|
||||
const BASE64_ALPHABET =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
export function utf8ByteLength(value: string) {
|
||||
let bytes = 0;
|
||||
@@ -85,6 +87,127 @@ function base64DecodedByteLength(value: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
function ensureImageBytesMatchMimeType(
|
||||
base64Data: string,
|
||||
mimeType: HostBridgeImageMimeType,
|
||||
) {
|
||||
if (detectImageMimeType(base64Data) !== mimeType) {
|
||||
throw invalidRequest('image bytes do not match MIME');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAudioBytesMatchMimeType(
|
||||
base64Data: string,
|
||||
mimeType: HostBridgeAudioMimeType,
|
||||
) {
|
||||
if (detectAudioMimeType(base64Data) !== mimeType) {
|
||||
throw invalidRequest('audio bytes do not match MIME');
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportTextFile(
|
||||
payload: unknown,
|
||||
): Promise<FileExportTextResult> {
|
||||
@@ -220,6 +343,7 @@ export async function exportImageFile(
|
||||
if (bytes > EXPORT_IMAGE_MAX_BYTES) {
|
||||
throw invalidRequest('image exceeds file export size limit');
|
||||
}
|
||||
ensureImageBytesMatchMimeType(base64Data, mimeType as HostBridgeImageMimeType);
|
||||
|
||||
const isSharingAvailable = await Sharing.isAvailableAsync();
|
||||
if (!isSharingAvailable) {
|
||||
@@ -298,6 +422,7 @@ function imagePickerResultToImportPayload(
|
||||
if (bytes <= 0 || bytes > IMPORT_IMAGE_MAX_BYTES) {
|
||||
throw invalidRequest('image exceeds file import size limit');
|
||||
}
|
||||
ensureImageBytesMatchMimeType(base64Data, mimeType);
|
||||
if (
|
||||
typeof asset.fileSize === 'number' &&
|
||||
asset.fileSize > IMPORT_IMAGE_MAX_BYTES
|
||||
@@ -435,6 +560,7 @@ export async function exportAudioFile(
|
||||
if (bytes <= 0 || bytes > EXPORT_AUDIO_MAX_BYTES) {
|
||||
throw invalidRequest('audio exceeds file export size limit');
|
||||
}
|
||||
ensureAudioBytesMatchMimeType(base64Data, mimeType as HostBridgeAudioMimeType);
|
||||
|
||||
const isSharingAvailable = await Sharing.isAvailableAsync();
|
||||
if (!isSharingAvailable) {
|
||||
@@ -502,11 +628,15 @@ export async function importAudioFile(): Promise<FileImportAudioResult> {
|
||||
}
|
||||
|
||||
const file = new File(asset.uri);
|
||||
const base64Data = await file.base64();
|
||||
const base64Data = normalizedBase64Data(await file.base64());
|
||||
if (!base64Data) {
|
||||
throw invalidRequest('base64Data is required');
|
||||
}
|
||||
const bytes = base64DecodedByteLength(base64Data);
|
||||
if (bytes <= 0 || bytes > IMPORT_AUDIO_MAX_BYTES) {
|
||||
throw invalidRequest('audio exceeds file import size limit');
|
||||
}
|
||||
ensureAudioBytesMatchMimeType(base64Data, mimeType);
|
||||
|
||||
return {
|
||||
action: 'selected',
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
- 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。
|
||||
- 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `<a download>` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。
|
||||
- 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。
|
||||
- 2026-06-18 移动壳文件 bytes 校验:Expo 图片 / 音频导入导出不得只信系统 picker 返回 MIME、文件扩展名或 H5 声明 MIME;移动壳必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM base64 bytes 头部,要求导入 MIME 归一结果与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。配置检查和移动壳测试覆盖该边界。
|
||||
- 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。
|
||||
- 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。
|
||||
- 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。
|
||||
|
||||
@@ -279,6 +279,8 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:移动壳声明并实现 `file.importText`,通过 Expo DocumentPicker 打开系统文档选择器,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI,也不开放通用文件系统。H5 创作 Agent 工作台在原生壳声明该能力时优先打开宿主系统选择器,再把返回文本转换成现有浏览器 `File` 并继续调用 `/api/runtime/creation-agent/document-inputs/parse`,不在前端绕过后端文档解析、大小校验或 docx 处理。
|
||||
|
||||
2026-06-18 追加:移动壳图片和音频导入 / 导出不再只信 Expo picker 返回的 MIME 或 H5 声明的 MIME。Expo 壳会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 base64 bytes 头部识别,导入时要求系统 picker 的 MIME 与 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。
|
||||
|
||||
2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Expo 壳仍只通过 `expo-clipboard` 返回纯文本,H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理;普通浏览器、小程序和未声明该能力的裁剪壳不显示粘贴动作。
|
||||
|
||||
2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Expo 壳仍只发送即时本地通知,不注册远程推送 token、不做定时提醒;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。
|
||||
|
||||
Reference in New Issue
Block a user