From d53bd66e99a001b41632039fa7b1882edd2a0c0c Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 22:31:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=8E=9F=E7=94=9F=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E6=A1=A5=E6=8E=A5=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Expo 分享目标状态收口到 share 模块 将 Tauri 分享目标与剪贴板 fallback 响应收口到 share 模块 同步两端分享结构门禁和共享记忆 --- apps/desktop-shell/scripts/check-config.mjs | 16 +++++ .../src-tauri/src/host_bridge/dispatch.rs | 45 ++----------- .../src-tauri/src/host_bridge/share.rs | 64 +++++++++++++++++-- apps/mobile-shell/scripts/check-config.mjs | 11 ++++ apps/mobile-shell/src/host-bridge/dispatch.ts | 17 +++-- apps/mobile-shell/src/host-bridge/share.ts | 16 ++++- .../shared-memory/decision-log.md | 1 + 7 files changed, 116 insertions(+), 54 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 555b66072..5cd41681a 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -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, )) { diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs index f9019e99c..0ab660914 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs @@ -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::(); - - 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::(); - 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), } } diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/share.rs b/apps/desktop-shell/src-tauri/src/host_bridge/share.rs index 379131dd8..35729087a 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/share.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/share.rs @@ -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::(); + + 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::(); + 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] diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index bed4fa675..27fd128a4 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -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'); } diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts index 2890bbcff..137bb6b32 100644 --- a/apps/mobile-shell/src/host-bridge/dispatch.ts +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -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(); } diff --git a/apps/mobile-shell/src/host-bridge/share.ts b/apps/mobile-shell/src/host-bridge/share.ts index e1f32c3f7..0cd872484 100644 --- a/apps/mobile-shell/src/host-bridge/share.ts +++ b/apps/mobile-shell/src/host-bridge/share.ts @@ -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'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f1900c8d3..2673ce388 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 不得本地重声明。