方洞结果页接入宿主图片导入

方洞结果页图片槽位在原生壳内优先调用 file.importImage

保留浏览器文件输入回退并覆盖原生导入、回退和取消选择测试

同步方洞玩法链路、HostBridge 方案和团队决策记录
This commit is contained in:
2026-06-18 19:02:21 +08:00
parent d71fde8b9a
commit ab27815810
6 changed files with 221 additions and 15 deletions
@@ -2478,3 +2478,10 @@
- 决策:三端桥接层按职责对齐,但保留各宿主真实边界。微信小程序页面路由不改,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol``webView``payment``shareGrid``subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面目录只保留生命周期和装配,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts``capabilities.ts``dispatch.ts``files.ts``share.ts` 和 facade `bridge.ts`,分别负责 envelope / request 校验 / ok-failure 响应 / replay 基础类型、能力清单与 iOS 差异、method 分发、文件能力、分享能力和 WebView message 入口 / request id replay 编排;根 `App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx``apps/mobile-shell/src/shell/*.ts(x)` 负责 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs``capabilities.rs``dispatch.rs``files.rs``share.rs` 和 command facade `mod.rs`,分别负责 envelope / method 白名单 / request 校验 / replay 状态、能力清单、method 分发、文件能力、分享能力和 `host_bridge_request` command / replay 编排;`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单,并拒绝移动根入口直接引用 HostBridge。
- 影响范围:`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-18 方洞结果页图片槽位接入原生壳图片导入
- 背景:方洞结果页的封面、背景、形状和洞口图片槽位已经支持浏览器文件输入、历史图选择、AI 生成和自动保存,但 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控 `file.importImage` 能力。
- 决策:`SquareHoleResultView` 图片槽位弹窗在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换为 `data:<mime>;base64,<data>` 并写回当前槽位 `imageSrc`。该动作继续走现有 result edit state、自动保存、试玩和发布链路,不新增后端上传路径,不暴露设备 URI、本机绝对路径或通用文件系统;普通浏览器、小程序和未声明能力的裁剪壳继续使用原浏览器文件输入。
- 影响范围:`src/components/square-hole-result/SquareHoleResultView.tsx``src/services/host-bridge/hostBridge.ts`、方洞玩法链路文档与 Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx``npm run typecheck -- --pretty false``npm run check:encoding``npm run check:native-shells``git diff --check`
File diff suppressed because one or more lines are too long
@@ -62,7 +62,7 @@ AI H5 sandbox
- `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。
- `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。
- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;反馈页上传凭证个人资料头像上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;反馈页上传凭证个人资料头像上传和方洞结果页图片槽位上传`native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg``audio/mp4``audio/wav``audio/ogg``audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力。H5 的通用音频输入面板 `CreativeAudioInputPanel``native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。单次音频不超过 20 MiB,成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob``fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。
@@ -379,6 +379,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
工程域:`square-hole`。当前链路具备 agent session、work profile、runtime run、drop shape、restart、stop、time-up 等后端 procedure 与前端 service。新改动应沿用独立 `module-square-hole``shared-contracts``spacetime-client`,不要挂回 RPG 或拼图语义。
方洞结果页的封面、背景、形状和洞口图片槽位继续归属现有 result edit state 与 `PUT square-hole work` 保存链路;在 Expo / Tauri 原生壳声明 `file.importImage` 时,“上传图片”优先调用 HostBridge 系统图片选择器,并把返回的图片内容副本转换为当前槽位 `imageSrc`,普通浏览器、小程序和未声明能力的壳仍保留浏览器文件输入。
## 大鱼吃小鱼
工程域:`big-fish`。当前保留创作会话、素材槽、事件、运行态 run、gallery、like、remix 和 play 记录。新创作和运行态规则应继续通过后端 profile / run 投影,不把运行态结果写成纯前端事实。
@@ -8,9 +8,13 @@ import {
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
import {
canUseNativeHostCapability,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { SquareHoleResultView } from './SquareHoleResultView';
vi.mock('../../services/square-hole-works', () => ({
@@ -22,6 +26,19 @@ vi.mock('../../services/square-hole-works', () => ({
updateSquareHoleWork: vi.fn(),
}));
vi.mock('../../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(() => false),
importHostImageFile: vi.fn(),
}));
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
const importHostImageFileMock = vi.mocked(importHostImageFile);
beforeEach(() => {
canUseNativeHostCapabilityMock.mockReturnValue(false);
importHostImageFileMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -308,6 +325,121 @@ test('square hole image dialog reuses platform media frame', async () => {
expect(mediaFrame?.className).toContain('bg-[var(--platform-subpanel-fill)]');
});
test('square hole image dialog imports slot images from native host capability', async () => {
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const { squareHoleAssetClient, updateSquareHoleWork } = await import(
'../../services/square-hole-works'
);
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
vi.mocked(updateSquareHoleWork).mockResolvedValue({
item: createProfile(),
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.importImage',
);
importHostImageFileMock.mockResolvedValue({
action: 'selected',
fileName: '方洞封面.png',
mimeType: 'image/png',
base64Data: 'bmF0aXZlLWNvdmVy',
bytes: 12,
});
try {
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看封面图' }));
expect(screen.getByRole('dialog', { name: '封面图查看' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '上传图片' }));
await waitFor(() => {
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
});
expect(inputClickSpy).not.toHaveBeenCalled();
await waitFor(() => {
expect(updateSquareHoleWork).toHaveBeenCalledWith(
'profile-1',
expect.objectContaining({
coverImageSrc: 'data:image/png;base64,bmF0aXZlLWNvdmVy',
}),
);
});
} finally {
inputClickSpy.mockRestore();
}
});
test('square hole image dialog keeps browser file input fallback when native import is unavailable', async () => {
const user = userEvent.setup();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const { squareHoleAssetClient } = await import(
'../../services/square-hole-works'
);
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
try {
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '查看封面图' }));
await screen.findByRole('dialog', { name: '封面图查看' });
await user.click(screen.getByRole('button', { name: '上传图片' }));
expect(importHostImageFileMock).not.toHaveBeenCalled();
expect(inputClickSpy).toHaveBeenCalledTimes(1);
} finally {
inputClickSpy.mockRestore();
}
});
test('square hole image dialog leaves slot image unchanged when native import is cancelled', async () => {
const { squareHoleAssetClient, updateSquareHoleWork } = await import(
'../../services/square-hole-works'
);
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
vi.mocked(updateSquareHoleWork).mockResolvedValue({
item: createProfile(),
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.importImage',
);
importHostImageFileMock.mockResolvedValue(false);
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看封面图' }));
expect(screen.getByRole('dialog', { name: '封面图查看' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '上传图片' }));
await waitFor(() => {
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
});
expect(updateSquareHoleWork).not.toHaveBeenCalled();
});
test('square hole cover and background buttons use platform media frames', () => {
render(
<SquareHoleResultView
@@ -9,7 +9,13 @@ import {
Send,
Trash2,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import {
type ChangeEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { SquareHoleResultDraft } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
@@ -26,6 +32,11 @@ import {
type SquareHoleImageAssetKind,
updateSquareHoleWork,
} from '../../services/square-hole-works';
import {
canUseNativeHostCapability,
type HostFileImportImageResult,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
@@ -305,6 +316,10 @@ function readImageAsDataUrl(file: File) {
});
}
function hostImageImportResultToDataUrl(result: HostFileImportImageResult) {
return `data:${result.mimeType};base64,${result.base64Data}`;
}
function createShapeOption(
index: number,
holeOptions: SquareHoleHoleOption[],
@@ -484,6 +499,7 @@ function SquareHoleImageSlotDialog({
onClose,
onRegenerateImages,
onSelectHistory,
onHostUpload,
onUpload,
}: {
currentImageSrc: string;
@@ -494,14 +510,17 @@ function SquareHoleImageSlotDialog({
onClose: () => void;
onRegenerateImages: () => void;
onSelectHistory: (asset: SquareHoleHistoryAsset) => void;
onHostUpload: (slot: SquareHoleImageSlot) => void;
onUpload: (
slot: SquareHoleImageSlot,
event: ChangeEvent<HTMLInputElement>,
) => void;
}) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const [assets, setAssets] = useState<SquareHoleHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canImportHostImage = canUseNativeHostCapability('file.importImage');
useEffect(() => {
let cancelled = false;
@@ -534,6 +553,17 @@ function SquareHoleImageSlotDialog({
};
}, [slot.assetKind]);
const handleUploadClick = () => {
if (isBusy) {
return;
}
if (canImportHostImage) {
onHostUpload(slot);
return;
}
uploadInputRef.current?.click();
};
return (
<PlatformToolModalShell
open
@@ -559,22 +589,23 @@ function SquareHoleImageSlotDialog({
/>
<div className="grid gap-2 sm:grid-cols-2">
<PlatformActionButton
asChild="label"
onClick={handleUploadClick}
disabled={isBusy}
tone="ghost"
size="md"
aria-disabled={isBusy}
className={`min-h-11 cursor-pointer gap-2 px-4 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
className="min-h-11 gap-2 px-4"
>
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept="image/*"
className="sr-only"
disabled={isBusy}
onChange={(event) => onUpload(slot, event)}
/>
</PlatformActionButton>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
className="sr-only"
disabled={isBusy}
onChange={(event) => onUpload(slot, event)}
/>
<PlatformActionButton
onClick={onRegenerateImages}
disabled={!canRegenerateImages || isBusy}
@@ -767,6 +798,37 @@ export function SquareHoleResultView({
}
};
const handleImageSlotHostUpload = async (slot: SquareHoleImageSlot) => {
if (
isBusy ||
isApplyingHistoryImage ||
isPublishing ||
isRegeneratingImages ||
isStartingTestRun
) {
return;
}
try {
const importedImage = await importHostImageFile();
if (!importedImage) {
return;
}
setEditState((current) =>
applyImageSlotSrc(
current,
slot,
hostImageImportResultToDataUrl(importedImage),
),
);
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '图片读取失败。',
);
}
};
const handleStartTestRun = async () => {
if (!canSubmit || isStartingTestRun) {
setLocalError(blockers[0] ?? null);
@@ -1420,6 +1482,9 @@ export function SquareHoleResultView({
onSelectHistory={(asset) => {
void handleSelectHistoryImage(activeImageSlot, asset);
}}
onHostUpload={(slot) => {
void handleImageSlotHostUpload(slot);
}}
onUpload={(slot, event) => {
void handleImageSlotUpload(slot, event);
}}