收口原生分享桥接边界

将 Expo 分享目标状态收口到 share 模块

将 Tauri 分享目标与剪贴板 fallback 响应收口到 share 模块

同步两端分享结构门禁和共享记忆
This commit is contained in:
2026-06-19 22:31:05 +08:00
parent b1c42885ba
commit d53bd66e99
7 changed files with 116 additions and 54 deletions
@@ -1480,6 +1480,10 @@ for (const expectedShareSnippet of [
'url.origin() != base_url.origin()',
'DesktopSharePayload::Invalid',
'"share target is invalid"',
'set_desktop_host_bridge_share_target',
'open_desktop_host_bridge_share',
'write_desktop_clipboard_text(app, &share_text)',
'"copied_to_clipboard"',
]) {
if (!desktopHostBridgeShareSource.includes(expectedShareSnippet)) {
throw new Error(
@@ -1488,6 +1492,18 @@ for (const expectedShareSnippet of [
}
}
if (
!desktopHostBridgeDispatchSource.includes(
'set_desktop_host_bridge_share_target(&app, &request)',
) ||
!desktopHostBridgeDispatchSource.includes('open_desktop_host_bridge_share(&app, &request)') ||
desktopHostBridgeDispatchSource.includes('DesktopShareState') ||
desktopHostBridgeDispatchSource.includes('share_text_from_request') ||
desktopHostBridgeDispatchSource.includes('"copied_to_clipboard"')
) {
throw new Error('desktop shell share HostBridge methods must delegate to share module');
}
for (const [limitName, sharedLimit] of Object.entries(
sharedHostBridgePayloadLimits,
)) {
@@ -2,8 +2,7 @@ use crate::host_bridge::appearance::desktop_appearance_color_scheme;
use crate::host_bridge::badge::set_desktop_app_badge_count;
use crate::host_bridge::capabilities::capabilities;
use crate::host_bridge::clipboard::{
read_desktop_host_bridge_clipboard_text, write_desktop_clipboard_text,
write_desktop_host_bridge_clipboard_text,
read_desktop_host_bridge_clipboard_text, write_desktop_host_bridge_clipboard_text,
};
use crate::host_bridge::files::{
export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload,
@@ -20,7 +19,9 @@ use crate::host_bridge::protocol::{
failed, ok, required_string_payload, validate_request, HostBridgeRequest, HostBridgeResponse,
HostBridgeRuntime, HOST_BRIDGE_VERSION,
};
use crate::host_bridge::share::{share_text_from_request, DesktopShareState};
use crate::host_bridge::share::{
open_desktop_host_bridge_share, set_desktop_host_bridge_share_target,
};
use crate::shell::webview::desktop_platform;
use serde_json::json;
use tauri::Manager;
@@ -302,42 +303,8 @@ pub(super) async fn execute_host_bridge_request(
Ok(()) => ok(request.id, json!(true)),
Err(response) => response,
},
"share.setTarget" => {
let target = request
.payload
.as_ref()
.and_then(|payload| payload.get("target"));
let Some(target) = target else {
return failed(request.id, "invalid_request", "target is required");
};
let share_state = app.state::<DesktopShareState>();
let response = match share_state.target.lock() {
Ok(mut current_target) => {
*current_target = Some(target.clone());
ok(request.id, json!(true))
}
Err(_) => failed(request.id, "host_error", "share target lock poisoned"),
};
response
}
"share.open" => {
let share_state = app.state::<DesktopShareState>();
let share_text = match share_text_from_request(&request, &share_state) {
Ok(text) => text,
Err(response) => return response,
};
match write_desktop_clipboard_text(&app, &share_text) {
Ok(()) => ok(
request.id,
json!({
"action": "copied_to_clipboard"
}),
),
Err(error) => failed(request.id, "host_error", error),
}
}
"share.setTarget" => set_desktop_host_bridge_share_target(&app, &request),
"share.open" => open_desktop_host_bridge_share(&app, &request),
_ => resolve_host_bridge_request(request),
}
}
@@ -1,7 +1,10 @@
use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse};
use crate::host_bridge::clipboard::write_desktop_clipboard_text;
use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
use crate::shell::webview::WEB_APP_ORIGIN;
use serde_json::json;
use serde_json::Value;
use std::sync::Mutex;
use tauri::Manager;
use tauri::Url;
#[derive(Debug, Default)]
@@ -63,7 +66,8 @@ fn share_text_from_value(value: &Value) -> DesktopSharePayload {
None => None,
};
let work_url = payload_string(payload, "work").map(work_detail_url);
let raw_path = payload_string(payload, "path").or_else(|| payload_string(payload, "targetPath"));
let raw_path =
payload_string(payload, "path").or_else(|| payload_string(payload, "targetPath"));
let path_url = match raw_path {
Some(path) => match normalize_public_share_url(path) {
Some(url) => Some(url),
@@ -129,6 +133,54 @@ pub(crate) fn share_text_from_request(
}
}
pub(crate) fn set_desktop_host_bridge_share_target(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let target = request
.payload
.as_ref()
.and_then(|payload| payload.get("target"));
let Some(target) = target else {
return failed(request.id.clone(), "invalid_request", "target is required");
};
let share_state = app.state::<DesktopShareState>();
let response = match share_state.target.lock() {
Ok(mut current_target) => {
*current_target = Some(target.clone());
ok(request.id.clone(), json!(true))
}
Err(_) => failed(
request.id.clone(),
"host_error",
"share target lock poisoned",
),
};
response
}
pub(crate) fn open_desktop_host_bridge_share(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let share_state = app.state::<DesktopShareState>();
let share_text = match share_text_from_request(request, &share_state) {
Ok(text) => text,
Err(response) => return response,
};
match write_desktop_clipboard_text(app, &share_text) {
Ok(()) => ok(
request.id.clone(),
json!({
"action": "copied_to_clipboard"
}),
),
Err(error) => failed(request.id.clone(), "host_error", error),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -218,9 +270,11 @@ mod tests {
"url": "javascript:alert(1)"
}));
assert!(!share_text_from_request(&javascript_url, &state)
.expect_err("unsafe share url")
.ok);
assert!(
!share_text_from_request(&javascript_url, &state)
.expect_err("unsafe share url")
.ok
);
}
#[test]
@@ -1285,12 +1285,23 @@ for (const snippet of [
'normalizeHostBridgeShareOpenPayload',
'const explicitPayload = normalizeHostBridgeShareOpenPayload(payload);',
'normalizeHostBridgeShareOpenPayload(currentShareTarget)',
'setMobileHostBridgeShareTarget',
'resetMobileHostBridgeShareTargetForTest',
]) {
if (!shareSource.includes(snippet)) {
throw new Error(`mobile shell share URL policy missing ${snippet}`);
}
}
if (
!dispatchSource.includes('setMobileHostBridgeShareTarget(request.payload)') ||
!dispatchSource.includes('openShare(request.payload)') ||
dispatchSource.includes('let currentShareTarget') ||
dispatchSource.includes('normalizeHostBridgeShareOpenPayload(currentShareTarget)')
) {
throw new Error('mobile shell share HostBridge methods must delegate to share module');
}
if (shareSource.includes("const WEB_APP_ORIGIN = 'https://app.genarrative.world'")) {
throw new Error('mobile shell share URL policy must reuse the shared web origin');
}
@@ -26,7 +26,11 @@ import {
unsupported,
} from './protocol';
import { resetQrScannerForTest, scanQrCode } from './scanner';
import { openShare } from './share';
import {
openShare,
resetMobileHostBridgeShareTargetForTest,
setMobileHostBridgeShareTarget,
} from './share';
import { showMobileHostBridgeLocalNotification } from './notifications';
import {
readMobileHostBridgeClipboardText,
@@ -42,7 +46,6 @@ import {
} from './navigation';
import { getMobileHostBridgeNetworkStatus } from './network';
let currentShareTarget: unknown = null;
let navigation: MobileHostBridgeNavigation | null = null;
export function configureMobileHostBridgeNavigation(
@@ -127,13 +130,9 @@ export async function dispatchMobileHostBridgeRequest(
case 'app.setBadgeCount':
return ok(request, setMobileAppBadgeCount(request.payload));
case 'share.open':
return ok(request, await openShare(request.payload, currentShareTarget));
return ok(request, await openShare(request.payload));
case 'share.setTarget':
currentShareTarget =
request.payload && typeof request.payload === 'object'
? (request.payload as { target?: unknown }).target
: null;
return ok(request, true);
return ok(request, setMobileHostBridgeShareTarget(request.payload));
case 'navigation.openNativePage':
return ok(
request,
@@ -148,7 +147,7 @@ export async function dispatchMobileHostBridgeRequest(
}
export function resetMobileHostBridgeDispatchForTest() {
currentShareTarget = null;
navigation = null;
resetMobileHostBridgeShareTargetForTest();
resetQrScannerForTest();
}
+15 -1
View File
@@ -3,7 +3,21 @@ import { Share } from 'react-native';
import { normalizeHostBridgeShareOpenPayload } from '../../../../packages/shared/src/contracts/hostBridge';
import { invalidRequest } from './protocol';
export async function openShare(payload: unknown, currentShareTarget: unknown) {
let currentShareTarget: unknown = null;
export function setMobileHostBridgeShareTarget(payload: unknown) {
currentShareTarget =
payload && typeof payload === 'object'
? (payload as { target?: unknown }).target
: null;
return true;
}
export function resetMobileHostBridgeShareTargetForTest() {
currentShareTarget = null;
}
export async function openShare(payload: unknown) {
const explicitPayload = normalizeHostBridgeShareOpenPayload(payload);
if (explicitPayload.status === 'invalid') {
throw invalidRequest('share target is invalid');
@@ -29,6 +29,7 @@
- 2026-06-19 移动壳外链打开 helper 共用:Expo WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `openMobileShellExternalNavigation` 执行系统外链打开动作;HostBridge 分支仍先调用 `normalizeHostBridgeExternalUrlPayload` 保留 payload 错误语义,但不再单独维护 `Linking.canOpenURL` / `Linking.openURL` 顺序。移动壳配置检查会拒绝 `app.openExternalUrl` 绕开该 helper,避免两条离壳路径漂移。
- 2026-06-19 移动壳系统分享 URL 边界:Expo `share.open` 调用 React Native 系统分享面板前,只允许把 `url``href``path``targetPath``work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。分享实现复用移动壳入口 URL 的生产主站 origin,配置检查会拒绝重新声明同值 origin 或移除协议相对 URL 拦截。
- 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url``href``path``targetPath``work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。
- 2026-06-19 原生壳分享桥接边界:Expo `share.setTarget` / `share.open` 的缓存目标、分享 payload 归一和系统分享调用统一收口在 `apps/mobile-shell/src/host-bridge/share.ts`Tauri `share.setTarget` / `share.open` 的缓存目标、分享文本生成、剪贴板 fallback 写入和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/share.rs`。两端 `dispatch` 只负责委托对应 share 模块,配置检查会拒绝分发层直接持有分享状态、生成分享文本或写入分享剪贴板结果。
- 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities``openHostShare``writeHostClipboardText``requestHostHapticsImpact``setHostAppTitle``exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。该回读请求的短超时由共享契约 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS` 声明,H5 facade 不得本地重声明。