diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 9cabc682a..0c52d3fc6 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1199,10 +1199,9 @@ if (webViewPolicySource.includes(`'${sharedPublicWebUrl}'`)) { } for (const snippet of [ - 'ALLOWED_PRODUCTION_WEB_ORIGIN', - 'normalizePublicShareUrl', - "rawUrl.startsWith('//')", - 'url.origin !== ALLOWED_PRODUCTION_WEB_ORIGIN', + 'normalizeHostBridgeShareOpenPayload', + 'const explicitPayload = normalizeHostBridgeShareOpenPayload(payload);', + 'normalizeHostBridgeShareOpenPayload(currentShareTarget)', ]) { if (!shareSource.includes(snippet)) { throw new Error(`mobile shell share URL policy missing ${snippet}`); diff --git a/apps/mobile-shell/src/host-bridge/share.ts b/apps/mobile-shell/src/host-bridge/share.ts index 871d2c7d8..e1f32c3f7 100644 --- a/apps/mobile-shell/src/host-bridge/share.ts +++ b/apps/mobile-shell/src/host-bridge/share.ts @@ -1,109 +1,10 @@ import { Share } from 'react-native'; -import { type ShareOpenPayload } from '../../../../packages/shared/src/contracts/hostBridge'; -import { ALLOWED_PRODUCTION_WEB_ORIGIN } from '../shell/url'; +import { normalizeHostBridgeShareOpenPayload } from '../../../../packages/shared/src/contracts/hostBridge'; import { invalidRequest } from './protocol'; -type SharePayloadNormalization = - | { status: 'empty' } - | { status: 'invalid' } - | { status: 'valid'; payload: ShareOpenPayload }; - -function stringField(value: unknown, field: string) { - if (!value || typeof value !== 'object') { - return undefined; - } - - const fieldValue = (value as Record)[field]; - if (typeof fieldValue !== 'string') { - return undefined; - } - - const text = fieldValue.trim(); - return text || undefined; -} - -function shareTargetPayload(value: unknown) { - if (!value || typeof value !== 'object') { - return value; - } - - const target = value as Record; - return target.target ?? value; -} - -function workDetailUrl(work: string) { - return `${ALLOWED_PRODUCTION_WEB_ORIGIN}/works/detail?work=${encodeURIComponent(work)}`; -} - -function normalizePublicShareUrl(rawUrl: string | undefined) { - if (!rawUrl) { - return undefined; - } - - if (rawUrl.startsWith('//')) { - return undefined; - } - - try { - const url = new URL(rawUrl, ALLOWED_PRODUCTION_WEB_ORIGIN); - if (url.origin !== ALLOWED_PRODUCTION_WEB_ORIGIN) { - return undefined; - } - - return url.toString(); - } catch { - return undefined; - } -} - -function normalizeSharePayload(value: unknown): SharePayloadNormalization { - const target = shareTargetPayload(value); - const payload = - target && typeof target === 'object' - ? (target as Record).payload ?? target - : target; - - if (!payload || typeof payload !== 'object') { - return { status: 'empty' }; - } - - const title = stringField(payload, 'title'); - const message = stringField(payload, 'message'); - const rawDirectUrl = stringField(payload, 'url') ?? stringField(payload, 'href'); - const directUrl = normalizePublicShareUrl(rawDirectUrl); - if (rawDirectUrl && !directUrl) { - return { status: 'invalid' }; - } - - const work = stringField(payload, 'work'); - const rawPath = stringField(payload, 'path') ?? stringField(payload, 'targetPath'); - const pathUrl = normalizePublicShareUrl(rawPath); - if (rawPath && !pathUrl) { - return { status: 'invalid' }; - } - - const url = - directUrl ?? - (work ? workDetailUrl(work) : undefined) ?? - pathUrl; - - if (!title && !message && !url) { - return { status: 'empty' }; - } - - return { - status: 'valid', - payload: { - ...(title ? { title } : {}), - ...(message ? { message } : {}), - ...(url ? { url } : {}), - }, - }; -} - export async function openShare(payload: unknown, currentShareTarget: unknown) { - const explicitPayload = normalizeSharePayload(payload); + const explicitPayload = normalizeHostBridgeShareOpenPayload(payload); if (explicitPayload.status === 'invalid') { throw invalidRequest('share target is invalid'); } @@ -111,7 +12,7 @@ export async function openShare(payload: unknown, currentShareTarget: unknown) { const cachedPayload = explicitPayload.status === 'valid' ? explicitPayload - : normalizeSharePayload(currentShareTarget); + : normalizeHostBridgeShareOpenPayload(currentShareTarget); if (cachedPayload.status === 'invalid') { throw invalidRequest('share target is invalid'); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f556b76b4..4de42109f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2571,7 +2571,7 @@ ## 2026-06-19 HostBridge 载荷边界单一来源 - 背景:文件导入导出、剪贴板、角标、本地通知和 request id 都已经在 Expo 与 Tauri 两套壳里有运行时校验;如果 MIME 清单、字节上限或文本长度只靠人工同步,新增文件类型或调整上限时会出现 H5 契约、移动壳和桌面壳互相漂移。 -- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、文档导入 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、窗口标题长度、外链 URL payload、剪贴板文本长度、触觉反馈 style 和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importDocument` / `file.importAudio` 必须在读取文本内容或 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。`app.openExternalUrl` 必须先通过共享 `normalizeHostBridgeExternalUrlPayload()` 清洗为 `{ url }`,H5 facade 和 Expo 移动壳都执行该边界,Tauri 壳用 Rust URL parser 镜像同一协议清单。`app.setTitle` 必须拒绝空值和控制字符,并按共享 80 字符上限截断;H5 facade 和 Tauri 壳都执行该边界。`clipboard.writeText` / `clipboard.readText` 两个方向都必须执行同一个 100000 字符上限;H5 facade 发起 `clipboard.writeText` 前先按共享上限归一化 payload,Expo 与 Tauri 壳仍必须再次执行同一边界,不允许只信 H5 facade 的预校验。H5 facade 发起 `haptics.impact` 前也必须按共享 style 清单归一化,未知 style 不发往宿主,Expo 壳仍二次拒绝未知值。`file.exportText` 的可选 `mimeType` 只能来自 `HOST_BRIDGE_TEXT_MIME_TYPES`,缺省为 `text/plain`,Expo 与 Tauri 都必须拒绝图片、音频或二进制 MIME,避免 H5 通过文本导出通道伪装落盘;两端 config check 必须反查该边界。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、文档导入 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、窗口标题长度、外链 URL payload、分享 payload、剪贴板文本长度、触觉反馈 style 和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importDocument` / `file.importAudio` 必须在读取文本内容或 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。`share.open` 必须通过共享 `normalizeHostBridgeShareOpenPayload()` 把 `url`、`href`、`path`、`targetPath` 和 `work` 归一到公开 H5 同源 URL,H5 facade 和 Expo 移动壳都执行该边界,Tauri 壳用 Rust URL parser 镜像同一规则。`app.openExternalUrl` 必须先通过共享 `normalizeHostBridgeExternalUrlPayload()` 清洗为 `{ url }`,H5 facade 和 Expo 移动壳都执行该边界,Tauri 壳用 Rust URL parser 镜像同一协议清单。`app.setTitle` 必须拒绝空值和控制字符,并按共享 80 字符上限截断;H5 facade 和 Tauri 壳都执行该边界。`clipboard.writeText` / `clipboard.readText` 两个方向都必须执行同一个 100000 字符上限;H5 facade 发起 `clipboard.writeText` 前先按共享上限归一化 payload,Expo 与 Tauri 壳仍必须再次执行同一边界,不允许只信 H5 facade 的预校验。H5 facade 发起 `haptics.impact` 前也必须按共享 style 清单归一化,未知 style 不发往宿主,Expo 壳仍二次拒绝未知值。`file.exportText` 的可选 `mimeType` 只能来自 `HOST_BRIDGE_TEXT_MIME_TYPES`,缺省为 `text/plain`,Expo 与 Tauri 都必须拒绝图片、音频或二进制 MIME,避免 H5 通过文本导出通道伪装落盘;两端 config check 必须反查该边界。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。 - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 8debabd11..d9da9869d 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -59,7 +59,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 桌面壳把分享文本写入系统剪贴板。两端都只能把 `url`、`href`、`path`、`targetPath` 和 `work` 归一到 `https://app.genarrative.world` 同源公开 URL,外域、协议相对 URL、危险协议和无法归一的显式分享目标必须返回 `invalid_request`,且不得回退到之前缓存的 `share.setTarget` 目标;宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。 +- `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 时显示失败并保留复制链接路径。 - `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` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 8b9706c46..7e59a1436 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -45,6 +45,7 @@ import { normalizeHostBridgeLocalNotification, normalizeHostBridgeQrCodeValue, normalizeHostBridgeRequestId, + normalizeHostBridgeShareOpenPayload, } from './hostBridge'; describe('HostBridge shared contract helpers', () => { @@ -388,6 +389,53 @@ describe('HostBridge shared contract helpers', () => { expect(normalizeHostBridgeLocalNotification(null)).toBeNull(); }); + test('归一化宿主分享载荷', () => { + expect( + normalizeHostBridgeShareOpenPayload({ + title: ' 暖灯猫街 ', + message: ' 来玩 ', + url: '/works/detail?work=PZ-1', + }), + ).toEqual({ + status: 'valid', + payload: { + title: '暖灯猫街', + message: '来玩', + url: 'https://app.genarrative.world/works/detail?work=PZ-1', + }, + }); + expect( + normalizeHostBridgeShareOpenPayload({ + title: '作品', + work: 'PZ 1/二', + }), + ).toEqual({ + status: 'valid', + payload: { + title: '作品', + url: 'https://app.genarrative.world/works/detail?work=PZ+1%2F%E4%BA%8C', + }, + }); + expect( + normalizeHostBridgeShareOpenPayload({ + title: '作品', + targetPath: '//app.genarrative.world/works/detail?work=PZ-1', + }), + ).toEqual({ status: 'invalid' }); + expect( + normalizeHostBridgeShareOpenPayload({ + title: '作品', + url: 'https://example.com/works/detail?work=PZ-1', + }), + ).toEqual({ status: 'invalid' }); + expect(normalizeHostBridgeShareOpenPayload({})).toEqual({ + status: 'empty', + }); + expect(normalizeHostBridgeShareOpenPayload(null)).toEqual({ + status: 'empty', + }); + }); + test('归一化宿主触觉反馈强度', () => { expect(normalizeHostBridgeHapticsImpactStyle(undefined)).toBe('light'); expect(normalizeHostBridgeHapticsImpactStyle('light')).toBe('light'); diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index a0f8f2f4c..f87f710b8 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -746,8 +746,109 @@ export type ShareOpenPayload = { title?: string; message?: string; url?: string; + href?: string; + path?: string; + targetPath?: string; + work?: string; }; +export type HostBridgeSharePayloadNormalization = + | { status: 'empty' } + | { status: 'invalid' } + | { status: 'valid'; payload: ShareOpenPayload }; + +function hostBridgeStringField(value: unknown, field: string) { + if (!value || typeof value !== 'object') { + return undefined; + } + + const fieldValue = (value as Record)[field]; + if (typeof fieldValue !== 'string') { + return undefined; + } + + const text = fieldValue.trim(); + return text || undefined; +} + +function hostBridgeShareTargetPayload(value: unknown) { + if (!value || typeof value !== 'object') { + return value; + } + + const target = value as Record; + return target.target ?? value; +} + +function normalizeHostBridgePublicShareUrl(rawUrl: string | undefined) { + if (!rawUrl || rawUrl.startsWith('//')) { + return undefined; + } + + try { + const url = new URL(rawUrl, HOST_BRIDGE_PUBLIC_WEB_ORIGIN); + if (url.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN) { + return undefined; + } + + return url.toString(); + } catch { + return undefined; + } +} + +function hostBridgeWorkDetailUrl(work: string) { + const searchParams = new URLSearchParams({ work }); + return `${HOST_BRIDGE_PUBLIC_WEB_ORIGIN}/works/detail?${searchParams.toString()}`; +} + +export function normalizeHostBridgeShareOpenPayload( + value: unknown, +): HostBridgeSharePayloadNormalization { + const target = hostBridgeShareTargetPayload(value); + const payload = + target && typeof target === 'object' + ? (target as Record).payload ?? target + : target; + + if (!payload || typeof payload !== 'object') { + return { status: 'empty' }; + } + + const title = hostBridgeStringField(payload, 'title'); + const message = hostBridgeStringField(payload, 'message'); + const rawDirectUrl = + hostBridgeStringField(payload, 'url') ?? + hostBridgeStringField(payload, 'href'); + const directUrl = normalizeHostBridgePublicShareUrl(rawDirectUrl); + if (rawDirectUrl && !directUrl) { + return { status: 'invalid' }; + } + + const work = hostBridgeStringField(payload, 'work'); + const rawPath = + hostBridgeStringField(payload, 'path') ?? + hostBridgeStringField(payload, 'targetPath'); + const pathUrl = normalizeHostBridgePublicShareUrl(rawPath); + if (rawPath && !pathUrl) { + return { status: 'invalid' }; + } + + const url = directUrl ?? (work ? hostBridgeWorkDetailUrl(work) : undefined) ?? pathUrl; + if (!title && !message && !url) { + return { status: 'empty' }; + } + + return { + status: 'valid', + payload: { + ...(title ? { title } : {}), + ...(message ? { message } : {}), + ...(url ? { url } : {}), + }, + }; +} + export const HOST_BRIDGE_FILE_NAME_FALLBACK = 'genarrative-export.txt'; export const HOST_BRIDGE_FILE_NAME_MAX_LENGTH = 120; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index f74702a91..042b32305 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -1047,6 +1047,19 @@ function assertH5HostBridgePayloadBoundaries() { 'H5 HostBridge facade must normalize app.openExternalUrl payloads with the shared external URL boundary', ); } + if ( + !h5HostBridgeSource.includes( + 'absolutizeHostSharePayloadUrls(params),', + ) || + !h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") || + !h5HostBridgeSource.includes( + "return await requestNativeHostBoolean(\n 'share.open',\n normalizedPayload.payload,\n );", + ) + ) { + throw new Error( + 'H5 HostBridge facade must normalize share.open payloads with the shared share boundary', + ); + } if ( !h5HostBridgeSource.includes( 'const clipboardText = normalizeHostBridgeClipboardText(text);', diff --git a/src/components/common/PublishShareModal.test.tsx b/src/components/common/PublishShareModal.test.tsx index c69443eee..e6b2f5c3d 100644 --- a/src/components/common/PublishShareModal.test.tsx +++ b/src/components/common/PublishShareModal.test.tsx @@ -9,6 +9,7 @@ import { } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; +import { HOST_BRIDGE_PUBLIC_WEB_ORIGIN } from '../../../packages/shared/src/contracts/hostBridge'; import * as clipboardService from '../../services/clipboard'; import { PublishShareModal } from './PublishShareModal'; import { @@ -195,13 +196,18 @@ describe('PublishShareModal', () => { within(dialog).getByRole('button', { name: '已打开' }), ).toBeTruthy(); }); + const rawShareUrl = new URL(buildPublishShareUrl(payload)); + const publicShareUrl = new URL( + `${rawShareUrl.pathname}${rawShareUrl.search}${rawShareUrl.hash}`, + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + ).toString(); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'share.open', payload: { title: '暖灯猫街', message: '邀请你来玩《暖灯猫街》\n作品号:PZ-00000001', - url: buildPublishShareUrl(payload), + url: publicShareUrl, }, }), }); diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 1ec3f40b8..dab831ea7 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -1045,6 +1045,12 @@ describe('hostBridge', () => { url: 'https://app.genarrative.world/works/detail?work=PZ-1', }), ).resolves.toBe(true); + await expect( + openHostShare({ + title: '危险作品', + url: 'https://example.com/works/detail?work=PZ-1', + }), + ).resolves.toBe(false); await expect( exportHostImageFile({ fileName: '分享卡.png', @@ -1204,6 +1210,12 @@ describe('hostBridge', () => { }, }), }); + expect( + invoke.mock.calls.filter(([, params]) => { + const request = (params as { request?: { method?: string } }).request; + return request?.method === 'share.open'; + }), + ).toHaveLength(1); expect(invoke).toHaveBeenCalledWith('host_bridge_request', { request: expect.objectContaining({ method: 'file.exportImage', diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index e845b9aba..a366dc776 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -30,6 +30,7 @@ import { HOST_BRIDGE_DOCUMENT_MIME_TYPES, HOST_BRIDGE_IMAGE_MIME_TYPES, HOST_BRIDGE_NATIVE_APP_QUERY, + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, HOST_BRIDGE_SCANNER_TIMEOUT_MS, @@ -47,6 +48,7 @@ import { normalizeHostBridgeLifecycleState, normalizeHostBridgeLocalNotification, normalizeHostBridgeQrCodeValue, + normalizeHostBridgeShareOpenPayload, } from '../../../packages/shared/src/contracts/hostBridge'; import type { WechatMiniProgramPayParams, @@ -585,6 +587,39 @@ function normalizeHostExternalUrlPayload(url: string) { } } +function absolutizeHostSharePayloadUrls(params: HostShareOpenRequest) { + if (typeof window === 'undefined') { + return params; + } + + const nextPayload: HostShareOpenRequest = { ...params }; + for (const field of ['url', 'href', 'path', 'targetPath'] as const) { + const value = nextPayload[field]; + if (typeof value !== 'string' || !value.trim()) { + continue; + } + + try { + const url = new URL(value.trim(), window.location.origin); + if ( + url.origin !== window.location.origin && + url.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN + ) { + nextPayload[field] = value; + continue; + } + nextPayload[field] = new URL( + `${url.pathname}${url.search}${url.hash}`, + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + ).toString(); + } catch { + nextPayload[field] = value; + } + } + + return nextPayload; +} + export function canUseHostShareGrid(context: HostRuntimeContext = {}) { return getHostRuntime(context).kind === 'wechat_mini_program'; } @@ -655,8 +690,18 @@ export async function openHostShare(params: HostShareOpenRequest) { return false; } + const normalizedPayload = normalizeHostBridgeShareOpenPayload( + absolutizeHostSharePayloadUrls(params), + ); + if (normalizedPayload.status !== 'valid') { + return false; + } + try { - return await requestNativeHostBoolean('share.open', params); + return await requestNativeHostBoolean( + 'share.open', + normalizedPayload.payload, + ); } catch { return false; }