收口原生分享桥接边界
将 Expo 分享目标状态收口到 share 模块 将 Tauri 分享目标与剪贴板 fallback 响应收口到 share 模块 同步两端分享结构门禁和共享记忆
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 错误回传给 H5,WebView 拦截保持 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 不得本地重声明。
|
||||
|
||||
Reference in New Issue
Block a user