修正桌面壳分享动作表达

Tauri 桌面分享按钮按剪贴板复制语义展示

Expo 移动分享继续保留系统分享表达

测试和门禁覆盖跨宿主分享文案差异

文档和项目记忆同步 share.open 能力语义
This commit is contained in:
2026-06-20 18:11:31 +08:00
parent ae3cdf4e69
commit 6f91ebc787
6 changed files with 130 additions and 10 deletions
@@ -3092,3 +3092,10 @@
- 决策:`apps/mobile-shell/scripts/check-config.mjs` 必须登记 `check-config.mjs``check-eas-build-config.mjs``check-expo-config.mjs``check-expo-export.mjs` 的完整脚本清单,并将 `check-config.mjs` 自身纳入生产替身词扫描;脚本内反查测试 mock 片段时使用字符串拼接保留测试约束,不让门禁自身违反生产规则。
- 影响范围:`apps/mobile-shell/scripts/check-config.mjs`
- 验证方式:`npm run mobile-shell:typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 原生分享动作按宿主真实表现展示
- 背景:`share.open` 是原生壳受控分享动作,不等同于每个宿主都打开系统分享面板;Expo 移动壳会打开系统分享面板,Tauri 桌面壳则把归一后的分享文本写入系统剪贴板并返回 `copied_to_clipboard`
- 决策:H5 发布分享弹窗必须按 `hostShell` 展示分享动作文案:`expo_mobile` 继续显示“系统分享 / 已打开 / 分享失败”,`tauri_desktop` 显示“复制分享文案 / 已复制 / 复制失败”;根级原生壳门禁反查 `PublishShareModal` 源码和测试,防止桌面剪贴板动作再次被包装成系统分享面板。
- 影响范围:`src/components/common/PublishShareModal.tsx``src/components/common/PublishShareModal.test.tsx``scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx``npm run check:native-shells``npm run check:encoding``git diff --check`
File diff suppressed because one or more lines are too long
@@ -68,7 +68,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗只在 `hostCapabilities` 声明 `share.open` 时展示“系统分享”,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,Tauri 桌面壳把分享文本写入系统剪贴板。H5 facade 和 Expo 移动壳都通过共享契约 `normalizeHostBridgeShareOpenPayload()``url``href``path``targetPath``work` 归一到 `https://app.genarrative.world` 同源公开 URL,Tauri 桌面壳用 Rust 镜像同一规则;外域、协议相对 URL、危险协议和无法归一的显式分享目标必须返回 `invalid_request`,且不得回退到之前缓存的 `share.setTarget` 目标;宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。
- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗只在 `hostCapabilities` 声明 `share.open` 时展示宿主分享动作,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,H5 展示“系统分享 / 已打开 / 分享失败”;Tauri 桌面壳把分享文本写入系统剪贴板,H5 展示“复制分享文案 / 已复制 / 复制失败”,不得把桌面剪贴板动作包装成系统分享面板。H5 facade 和 Expo 移动壳都通过共享契约 `normalizeHostBridgeShareOpenPayload()``url``href``path``targetPath``work` 归一到 `https://app.genarrative.world` 同源公开 URL,Tauri 桌面壳用 Rust 镜像同一规则;外域、协议相对 URL、危险协议和无法归一的显式分享目标必须返回 `invalid_request`,且不得回退到之前缓存的 `share.setTarget` 目标;宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。
- `openHostShareGrid()`:微信小程序九宫格切图页。
- `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;H5 facade 发起请求前先按共享契约 `HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH` 归一化 payload,减少 WebView bridge 承载超长文本;两端壳写入前也必须执行同一上限截断,不能让 H5 透传超长剪贴板内容;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。
- `readHostClipboardText()`:原生 App 宿主的受控剪贴板读取入口。H5 只能读取纯文本结果,宿主返回内容会按 HostBridge 契约限制到 100000 字符;Expo 移动壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 桌面壳通过 Rust 侧 `clipboard-manager` 读取系统剪贴板文本。该能力不读取图片、HTML、文件列表或剪贴板监听事件,不把 Tauri / Expo 剪贴板插件 API 直接暴露给 H5;宿主未声明或读取失败时由 H5 视作失败并保留原流程。个人中心的邀请码和兑换码弹窗只在宿主声明 `clipboard.readText` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。
+44
View File
@@ -2055,6 +2055,47 @@ function assertH5NativeAppRouteFlows() {
}
}
function assertH5NativeSharePresentation() {
const modalSource = fs.readFileSync(
'src/components/common/PublishShareModal.tsx',
'utf8',
);
const modalTestSource = fs.readFileSync(
'src/components/common/PublishShareModal.test.tsx',
'utf8',
);
for (const snippet of [
'function resolveNativeSharePresentation',
"hostShell === 'tauri_desktop'",
"idleLabel: '复制分享文案'",
"successLabel: '已复制'",
"failedLabel: '复制失败'",
"idleLabel: '系统分享'",
"successLabel: '已打开'",
"failedLabel: '分享失败'",
]) {
if (!modalSource.includes(snippet)) {
throw new Error(`PublishShareModal native share presentation missing: ${snippet}`);
}
}
for (const snippet of [
'uses clipboard copy wording for Tauri native host share action',
'hostShell=tauri_desktop',
"queryByRole('button', { name: '系统分享' })",
"getByRole('button', { name: '复制分享文案' })",
"getByRole('button', { name: '已复制' })",
'keeps system share wording for Expo native host share action',
'hostShell=expo_mobile',
"getByRole('button', { name: '系统分享' })",
"getByRole('button', { name: '已打开' })",
]) {
if (!modalTestSource.includes(snippet)) {
throw new Error(`PublishShareModal native share test missing: ${snippet}`);
}
}
}
function assertWechatPaymentResultBoundaries() {
const paymentSource = fs.readFileSync(
'miniprogram/host-bridge/payment.js',
@@ -2466,6 +2507,9 @@ assertWechatMiniProgramCapabilityFlows();
console.log('[check:native-shells] h5-native-app-route-flows');
assertH5NativeAppRouteFlows();
console.log('[check:native-shells] h5-native-share-presentation');
assertH5NativeSharePresentation();
console.log('[check:native-shells] wechat-payment-result-boundaries');
assertWechatPaymentResultBoundaries();
@@ -162,7 +162,7 @@ describe('PublishShareModal', () => {
expect(screen.getByRole('button', { name: '九宫切图' })).toBeTruthy();
});
test('uses native host share action inside native app runtime', async () => {
test('uses clipboard copy wording for Tauri native host share action', async () => {
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string } }).request;
@@ -189,11 +189,14 @@ describe('PublishShareModal', () => {
render(<PublishShareModal open payload={payload} onClose={() => {}} />);
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
fireEvent.click(within(dialog).getByRole('button', { name: '系统分享' }));
expect(within(dialog).queryByRole('button', { name: '系统分享' })).toBeNull();
fireEvent.click(
within(dialog).getByRole('button', { name: '复制分享文案' }),
);
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '已打开' }),
within(dialog).getByRole('button', { name: '已复制' }),
).toBeTruthy();
});
const rawShareUrl = new URL(buildPublishShareUrl(payload));
@@ -213,6 +216,45 @@ describe('PublishShareModal', () => {
});
});
test('keeps system share wording for Expo native host share action', async () => {
window.history.replaceState(
null,
'',
'/?clientRuntime=native_app&hostShell=expo_mobile&hostCapabilities=share.open',
);
const postMessage = vi.fn((rawMessage: string) => {
const request = JSON.parse(rawMessage) as { id: string };
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: true,
result: true,
}),
}),
);
});
window.ReactNativeWebView = {
postMessage,
};
render(<PublishShareModal open payload={payload} onClose={() => {}} />);
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
fireEvent.click(within(dialog).getByRole('button', { name: '系统分享' }));
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '已打开' }),
).toBeTruthy();
});
expect(postMessage).toHaveBeenCalledWith(
expect.stringContaining('"method":"share.open"'),
);
});
test('uses native host image export for share card download inside native app runtime', async () => {
class MockFileReader {
result: string | ArrayBuffer | null = null;
+31 -4
View File
@@ -28,6 +28,12 @@ type PublishShareModalProps = {
type ActionState = 'idle' | 'success' | 'failed';
type NativeSharePresentation = {
idleLabel: string;
successLabel: string;
failedLabel: string;
};
function normalizePayloadTitle(payload: PublishShareModalPayload | null) {
return payload?.title.trim() || '我的作品';
}
@@ -44,6 +50,24 @@ function resolvePayloadWorkTypeLabel(payload: PublishShareModalPayload | null) {
return payload?.workTypeLabel?.trim() || '互动作品';
}
function resolveNativeSharePresentation(
hostShell: string | null,
): NativeSharePresentation {
if (hostShell === 'tauri_desktop') {
return {
idleLabel: '复制分享文案',
successLabel: '已复制',
failedLabel: '复制失败',
};
}
return {
idleLabel: '系统分享',
successLabel: '已打开',
failedLabel: '分享失败',
};
}
/**
* 发布完成后的通用分享弹窗。
* 分享事实仍来自公开作品号与 stage;弹窗只负责把它表现成可复制、可下载的分享卡。
@@ -60,7 +84,10 @@ export function PublishShareModal({
const [nativeShareState, setNativeShareState] =
useState<ActionState>('idle');
const resetTimerRef = useRef<number | null>(null);
const hostRuntimeKind = getHostRuntime().kind;
const hostRuntime = getHostRuntime();
const hostRuntimeKind = hostRuntime.kind;
const hostShell = hostRuntime.hostShell;
const nativeSharePresentation = resolveNativeSharePresentation(hostShell);
const shareCopyUrl = useMemo(
() =>
payload
@@ -230,10 +257,10 @@ export function PublishShareModal({
<Share2 className="h-4 w-4" />
)}
{nativeShareState === 'success'
? '已打开'
? nativeSharePresentation.successLabel
: nativeShareState === 'failed'
? '分享失败'
: '系统分享'}
? nativeSharePresentation.failedLabel
: nativeSharePresentation.idleLabel}
</button>
) : null}
<button