修正桌面壳分享动作表达
Tauri 桌面分享按钮按剪贴板复制语义展示 Expo 移动分享继续保留系统分享表达 测试和门禁覆盖跨宿主分享文案差异 文档和项目记忆同步 share.open 能力语义
This commit is contained in:
@@ -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` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user