收口原生剪贴板桥接边界

将 Expo 剪贴板 HostBridge 读写收口到 clipboard 模块

将 Tauri 剪贴板 HostBridge 响应收口到 clipboard 模块

同步原生壳结构门禁、架构文档和共享记忆
This commit is contained in:
2026-06-19 22:18:54 +08:00
parent c0940bc790
commit 1d6fda4696
9 changed files with 127 additions and 57 deletions
+26 -3
View File
@@ -1719,6 +1719,30 @@ for (const snippet of [
}
}
if (
!desktopHostBridgeDispatchSource.includes(
'write_desktop_host_bridge_clipboard_text(&app, &request)',
) ||
!desktopHostBridgeDispatchSource.includes(
'read_desktop_host_bridge_clipboard_text(&app, &request)',
) ||
desktopHostBridgeDispatchSource.includes('required_string_payload(&request, "text")') ||
desktopHostBridgeDispatchSource.includes('read_desktop_clipboard_text(&app)')
) {
throw new Error('desktop shell clipboard HostBridge methods must delegate to clipboard module');
}
for (const snippet of [
'write_desktop_clipboard_text(app, text)',
'read_desktop_clipboard_text(app)',
'normalize_clipboard_text(&text)',
'required_string_payload(request, "text")',
'"text": text',
]) {
if (!desktopHostBridgeClipboardSource.includes(snippet)) {
throw new Error(`desktop shell clipboard module is missing ${snippet}`);
}
}
if (config.build?.frontendDist !== '../../../dist') {
throw new Error('desktop shell must package the root H5 dist');
}
@@ -1986,9 +2010,8 @@ const requiredRustHostSnippets = [
'"file.exportAudio"',
'"file.imageDropped"',
'"notification.showLocal"',
'write_desktop_clipboard_text(&app, text)',
'read_desktop_clipboard_text(&app)',
'normalize_clipboard_text(&text)',
'write_desktop_host_bridge_clipboard_text(&app, &request)',
'read_desktop_host_bridge_clipboard_text(&app, &request)',
'tauri_plugin_dialog::init()',
'tauri_plugin_notification::init()',
'tauri_plugin_notification::{NotificationExt, PermissionState}',
@@ -1,5 +1,10 @@
use tauri_plugin_clipboard_manager::ClipboardExt;
use crate::host_bridge::protocol::{
failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse,
};
use serde_json::json;
pub(crate) const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000;
pub(crate) fn normalize_clipboard_text(text: &str) -> String {
@@ -22,6 +27,36 @@ pub(crate) fn read_desktop_clipboard_text(app: &tauri::AppHandle) -> Result<Stri
.map_err(|error| error.to_string())
}
pub(crate) fn write_desktop_host_bridge_clipboard_text(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let text = match required_string_payload(request, "text") {
Ok(text) => text,
Err(response) => return response,
};
match write_desktop_clipboard_text(app, text) {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(error) => failed(request.id.clone(), "host_error", error),
}
}
pub(crate) fn read_desktop_host_bridge_clipboard_text(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
match read_desktop_clipboard_text(app) {
Ok(text) => ok(
request.id.clone(),
json!({
"text": text,
}),
),
Err(error) => failed(request.id.clone(), "host_error", error),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -34,4 +69,22 @@ mod tests {
CLIPBOARD_TEXT_MAX_LENGTH
);
}
#[test]
fn clipboard_read_response_reports_contract_shape() {
let response = ok(
"clipboard-read".to_string(),
json!({
"text": normalize_clipboard_text("邀请码 GEN-1")
}),
);
assert!(response.ok);
assert_eq!(
response.result.expect("clipboard result"),
json!({
"text": "邀请码 GEN-1"
})
);
}
}
@@ -1,7 +1,10 @@
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_clipboard_text, write_desktop_clipboard_text};
use crate::host_bridge::clipboard::{
read_desktop_host_bridge_clipboard_text, write_desktop_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,
import_document_file_payload, import_image_file_payload, import_text_file_payload,
@@ -68,32 +71,12 @@ pub(super) async fn execute_host_bridge_request(
}
match request.method.as_str() {
"app.openExternalUrl" => {
open_desktop_host_bridge_external_url(&app, &request)
}
"app.openExternalUrl" => open_desktop_host_bridge_external_url(&app, &request),
"appearance.getColorScheme" => desktop_appearance_color_scheme(&app, &request),
"navigation.openNativePage" => open_desktop_host_bridge_native_page(&app, &request),
"app.reloadWebView" => reload_desktop_host_bridge_webview(&app, &request),
"clipboard.writeText" => {
let text = match required_string_payload(&request, "text") {
Ok(text) => text,
Err(response) => return response,
};
match write_desktop_clipboard_text(&app, text) {
Ok(()) => ok(request.id, json!(true)),
Err(error) => failed(request.id, "host_error", error),
}
}
"clipboard.readText" => match read_desktop_clipboard_text(&app) {
Ok(text) => ok(
request.id,
json!({
"text": text,
}),
),
Err(error) => failed(request.id, "host_error", error),
},
"clipboard.writeText" => write_desktop_host_bridge_clipboard_text(&app, &request),
"clipboard.readText" => read_desktop_host_bridge_clipboard_text(&app, &request),
"file.exportText" => {
let (file_name, content) = match export_text_payload(&request) {
Ok(payload) => payload,
@@ -311,12 +294,10 @@ pub(super) async fn execute_host_bridge_request(
Ok(()) => ok(request.id, json!(true)),
Err(response) => response,
},
"network.status" => {
match resolve_desktop_host_bridge_network_status().await {
Ok(status) => ok(request.id, status),
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"network.status" => match resolve_desktop_host_bridge_network_status().await {
Ok(status) => ok(request.id, status),
Err(error) => failed(request.id, "host_error", error.to_string()),
},
"notification.showLocal" => match show_desktop_local_notification(&app, &request) {
Ok(()) => ok(request.id, json!(true)),
Err(response) => response,
@@ -491,5 +472,4 @@ mod tests {
80
);
}
}
+14 -2
View File
@@ -1666,13 +1666,25 @@ if (
dispatchSource.includes("from 'expo-clipboard'") ||
dispatchSource.includes('Clipboard.setStringAsync') ||
dispatchSource.includes('Clipboard.getStringAsync') ||
!dispatchSource.includes('writeMobileClipboardText(') ||
!dispatchSource.includes('readMobileClipboardText()')
dispatchSource.includes('ClipboardWriteTextPayload') ||
!dispatchSource.includes('writeMobileHostBridgeClipboardText(request.payload)') ||
!dispatchSource.includes('readMobileHostBridgeClipboardText()')
) {
throw new Error(
'mobile shell dispatch must delegate clipboard IO to clipboard.ts',
);
}
for (const snippet of [
'writeMobileHostBridgeClipboardText',
'type ClipboardWriteTextPayload',
'writeMobileClipboardText(text)',
'readMobileHostBridgeClipboardText',
'readMobileClipboardText()',
]) {
if (!clipboardSource.includes(snippet)) {
throw new Error(`mobile shell clipboard module is missing ${snippet}`);
}
}
if (
!hapticsSource.includes('normalizeHostBridgeHapticsImpactStyle(rawStyle)') ||
@@ -2,9 +2,11 @@ import * as Clipboard from 'expo-clipboard';
import {
type ClipboardReadTextResult,
type ClipboardWriteTextPayload,
type HostBridgeError,
normalizeHostBridgeClipboardText,
} from '../../../../packages/shared/src/contracts/hostBridge';
import { invalidRequest } from './protocol';
export async function writeMobileClipboardText(rawText: unknown) {
const clipboardText = normalizeHostBridgeClipboardText(rawText);
@@ -16,6 +18,19 @@ export async function writeMobileClipboardText(rawText: unknown) {
return true;
}
export async function writeMobileHostBridgeClipboardText(payload: unknown) {
const text = (payload as ClipboardWriteTextPayload | undefined)?.text;
if (!(await writeMobileClipboardText(text))) {
throw invalidRequest('text is required');
}
return true;
}
export function readMobileHostBridgeClipboardText() {
return readMobileClipboardText();
}
export async function readMobileClipboardText(): Promise<ClipboardReadTextResult> {
const result = normalizeHostBridgeClipboardText(
await Clipboard.getStringAsync(),
+4 -17
View File
@@ -2,7 +2,6 @@ import * as Linking from 'expo-linking';
import { Platform } from 'react-native';
import {
type ClipboardWriteTextPayload,
type HapticsImpactPayload,
HOST_BRIDGE_VERSION,
type HostBridgeRequest,
@@ -31,8 +30,8 @@ import { resetQrScannerForTest, scanQrCode } from './scanner';
import { openShare } from './share';
import { showMobileLocalNotification } from './notifications';
import {
readMobileClipboardText,
writeMobileClipboardText,
readMobileHostBridgeClipboardText,
writeMobileHostBridgeClipboardText,
} from './clipboard';
import { runMobileHapticsImpact } from './haptics';
import { setMobileAppBadgeCount } from './badge';
@@ -53,18 +52,6 @@ export function configureMobileHostBridgeNavigation(
navigation = nextNavigation;
}
async function writeClipboard(payload: unknown) {
if (
!(await writeMobileClipboardText(
(payload as ClipboardWriteTextPayload | undefined)?.text,
))
) {
throw invalidRequest('text is required');
}
return true;
}
async function runHaptics(payload: unknown) {
if (
!(await runMobileHapticsImpact(
@@ -119,9 +106,9 @@ export async function dispatchMobileHostBridgeRequest(
case 'network.status':
return ok(request, await getMobileHostBridgeNetworkStatus());
case 'clipboard.writeText':
return ok(request, await writeClipboard(request.payload));
return ok(request, await writeMobileHostBridgeClipboardText(request.payload));
case 'clipboard.readText':
return ok(request, await readMobileClipboardText());
return ok(request, await readMobileHostBridgeClipboardText());
case 'file.exportText':
return ok(request, await exportTextFile(request.payload));
case 'file.importText':
@@ -83,8 +83,8 @@
- 2026-06-18 草稿生成完成 / 失败通知:平台壳层的 `markDraftReady` / `markDraftFailed` 统一收口会在原生壳声明 `notification.showLocal` 时请求即时本地通知;通知 payload 只包含生成完成 / 失败标题和草稿来源正文,按草稿来源去重,同一草稿重新进入生成中后才允许再次通知。该能力不替代现有完成 / 错误弹窗、作品架红点、队列概览或后端状态回读,通知失败不阻断主流程。根级原生壳门禁必须覆盖平台壳同步层通过真实 HostBridge transport 发出 `notification.showLocal`,避免只测模型文案。
- 2026-06-19 草稿生成 HostBridge 消费门禁:`PlatformEntryFlowShellImpl` 只负责派生草稿通知和未读数量,实际宿主同步经 `platformHostBridgeSync.ts` 调用 `showHostLocalNotification` / `setHostAppBadgeCount``npm run check:native-shells` 必须运行该同步层的真实 Tauri transport 测试,并继续覆盖通知模型、未读计数模型、音频导入和文档导入等 H5 HostBridge 消费测试。
- 2026-06-18 剪贴板读取能力:新增 `clipboard.readText` HostBridge capabilityH5 只能读取纯文本结果,契约限制返回文本最多 100000 字符;Expo 壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 壳通过 Rust 侧 `tauri-plugin-clipboard-manager` 读取文本且不开放插件 JS guest API。该能力不读取图片、HTML、文件列表或剪贴板监听事件,宿主未声明或读取失败时由 H5 视作失败并保留原流程。
- 2026-06-19 移动壳剪贴板边界:Expo `clipboard.writeText` / `clipboard.readText` 的系统剪贴板读写共享 100000 字符归一统一收口在 `apps/mobile-shell/src/host-bridge/clipboard.ts``dispatch.ts` 只负责 HostBridge payload 取 `text` 并调用 `writeMobileClipboardText(...)` / `readMobileClipboardText()`,不得直接导入 `expo-clipboard` 或调用 `Clipboard.setStringAsync` / `Clipboard.getStringAsync`。移动壳配置检查会覆盖该模块结构、共享文本边界和 dispatch 委托关系,避免剪贴板能力散落到分发层。
- 2026-06-19 桌面壳剪贴板边界:Tauri `clipboard.writeText` / `clipboard.readText` 的系统剪贴板读写共享 100000 字符归一统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs``dispatch.rs` 只负责 HostBridge payload 取 `text` 并调用 `write_desktop_clipboard_text(...)` / `read_desktop_clipboard_text(...)`,不得直接承接剪贴板文本截断或插件读写细节。桌面壳配置检查会覆盖该模块结构、共享文本边界和 dispatch 委托关系,避免剪贴板能力散落到分发层。
- 2026-06-19 移动壳剪贴板边界:Expo `clipboard.writeText` / `clipboard.readText` 的系统剪贴板读写共享 100000 字符归一、payload 校验和响应边界统一收口在 `apps/mobile-shell/src/host-bridge/clipboard.ts``dispatch.ts` 只负责 HostBridge request 委托给 `writeMobileHostBridgeClipboardText(...)` / `readMobileHostBridgeClipboardText()`,不得直接导入 `expo-clipboard` 或调用 `Clipboard.setStringAsync` / `Clipboard.getStringAsync`。移动壳配置检查会覆盖该模块结构、共享文本边界和 dispatch 委托关系,避免剪贴板能力散落到分发层。
- 2026-06-19 桌面壳剪贴板边界:Tauri `clipboard.writeText` / `clipboard.readText` 的系统剪贴板读写共享 100000 字符归一、payload 校验和响应边界统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs``dispatch.rs` 只负责 HostBridge request 委托给 `write_desktop_host_bridge_clipboard_text(...)` / `read_desktop_host_bridge_clipboard_text(...)`,不得直接承接剪贴板文本截断、payload 解析或插件读写细节;桌面 share fallback 仍可调用底层写剪贴板函数复制分享文本。桌面壳配置检查会覆盖该模块结构、共享文本边界和 dispatch 委托关系,避免剪贴板能力散落到分发层。
- 2026-06-19 桌面壳本地通知边界:Tauri `notification.showLocal` 的 payload 清洗、权限状态检查、prompt 权限请求和系统通知发送统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs``dispatch.rs` 只负责把 HostBridge request 委托给 `show_desktop_local_notification(...)` 并映射响应,不得直接调用 `app.notification()``NotificationExt``PermissionState`。桌面壳配置检查会覆盖该模块结构、权限语义和 dispatch 委托关系,避免本地通知能力散落到分发层。
- 2026-06-18 文本文件导入能力:新增 `file.importText` HostBridge capabilityH5 统一通过 `importHostTextFile()` 读取宿主返回的纯文本内容;Expo 壳通过 `expo-document-picker` 打开系统文档选择器,Tauri 壳通过系统文件选择框读取真实文本文件。两端只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备 URI / 本机绝对路径,也不开放通用文件系统。
- 2026-06-19 文档文件导入能力:新增 `file.importDocument` HostBridge capability,作为创作 Agent 工作台优先导入路径;Expo 壳通过 DocumentPicker、Tauri 壳通过系统文件选择框读取文本类文档或 DOCX 副本。两端只接受文本 MIME / DOCX MIME 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备 URI、本机绝对路径,也不开放通用文件系统;H5 把返回内容转换成浏览器 `File` 后继续走后端 `/api/runtime/creation-agent/document-inputs/parse`,不在前端解析 DOCX。旧壳只声明 `file.importText` 时继续使用文本导入兜底。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long