收口桌面剪贴板边界
将 Tauri 剪贴板读写收口到 clipboard 模块 让桌面 HostBridge dispatch 只负责剪贴板 payload 分发 同步原生壳结构门禁和架构文档清单
This commit is contained in:
@@ -54,6 +54,14 @@ const desktopHostBridgeCapabilitiesSource = fs.readFileSync(
|
||||
desktopHostBridgeCapabilitiesPath,
|
||||
'utf8',
|
||||
);
|
||||
const desktopHostBridgeClipboardPath = new URL(
|
||||
'../src-tauri/src/host_bridge/clipboard.rs',
|
||||
import.meta.url,
|
||||
);
|
||||
const desktopHostBridgeClipboardSource = fs.readFileSync(
|
||||
desktopHostBridgeClipboardPath,
|
||||
'utf8',
|
||||
);
|
||||
const desktopHostBridgeDispatchPath = new URL(
|
||||
'../src-tauri/src/host_bridge/dispatch.rs',
|
||||
import.meta.url,
|
||||
@@ -178,6 +186,7 @@ const expectedDesktopRustRootEntries = [
|
||||
];
|
||||
const expectedDesktopHostBridgeRustFiles = [
|
||||
'capabilities.rs',
|
||||
'clipboard.rs',
|
||||
'dispatch.rs',
|
||||
'files.rs',
|
||||
'mod.rs',
|
||||
@@ -893,7 +902,12 @@ function extractDesktopHostBridgeMethodBody(source, method) {
|
||||
throw new Error(`desktop shell HostBridge missing method ${method}`);
|
||||
}
|
||||
|
||||
const nextMethodStart = source.indexOf('\n "', methodStart + method.length);
|
||||
const nextMethodMatch = source
|
||||
.slice(methodStart + method.length)
|
||||
.match(/\n "[^"]+"\s*=>/);
|
||||
const nextMethodStart = nextMethodMatch
|
||||
? methodStart + method.length + nextMethodMatch.index
|
||||
: -1;
|
||||
return source.slice(
|
||||
methodStart,
|
||||
nextMethodStart > methodStart ? nextMethodStart : undefined,
|
||||
@@ -903,7 +917,7 @@ function extractDesktopHostBridgeMethodBody(source, method) {
|
||||
function extractDesktopDialogFilter(source, method, filterLabel) {
|
||||
const methodBody = extractDesktopHostBridgeMethodBody(source, method);
|
||||
const match = methodBody.match(
|
||||
new RegExp(`\\.add_filter\\("${filterLabel}",\\s*&\\[([^\\]]*)\\]\\)`),
|
||||
new RegExp(`\\.add_filter\\(\\s*"${filterLabel}",\\s*&\\[([^\\]]*)\\]\\s*,?\\s*\\)`),
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
@@ -1268,7 +1282,7 @@ const desktopHostBridgePayloadLimits = {
|
||||
'WINDOW_TITLE_MAX_LENGTH',
|
||||
),
|
||||
HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractRustNumberConst(
|
||||
rustHostSource,
|
||||
desktopHostBridgeClipboardSource,
|
||||
'CLIPBOARD_TEXT_MAX_LENGTH',
|
||||
),
|
||||
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractRustNumberConst(
|
||||
@@ -1781,6 +1795,7 @@ const expectedRustRootEntries = [
|
||||
];
|
||||
const expectedRustHostBridgeFiles = [
|
||||
'capabilities.rs',
|
||||
'clipboard.rs',
|
||||
'dispatch.rs',
|
||||
'files.rs',
|
||||
'mod.rs',
|
||||
@@ -1878,7 +1893,9 @@ const requiredRustHostSnippets = [
|
||||
'"file.exportAudio"',
|
||||
'"file.imageDropped"',
|
||||
'"notification.showLocal"',
|
||||
'Ok(text) => normalize_clipboard_text(text)',
|
||||
'write_desktop_clipboard_text(&app, text)',
|
||||
'read_desktop_clipboard_text(&app)',
|
||||
'normalize_clipboard_text(&text)',
|
||||
'tauri_plugin_dialog::init()',
|
||||
'tauri_plugin_notification::init()',
|
||||
'tauri_plugin_notification::{NotificationExt, PermissionState}',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
|
||||
pub(crate) const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000;
|
||||
|
||||
pub(crate) fn normalize_clipboard_text(text: &str) -> String {
|
||||
text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn write_desktop_clipboard_text(
|
||||
app: &tauri::AppHandle,
|
||||
text: &str,
|
||||
) -> Result<(), String> {
|
||||
app.clipboard()
|
||||
.write_text(normalize_clipboard_text(text))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn read_desktop_clipboard_text(app: &tauri::AppHandle) -> Result<String, String> {
|
||||
app.clipboard()
|
||||
.read_text()
|
||||
.map(|text| normalize_clipboard_text(&text))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clipboard_text_is_truncated_to_contract_limit() {
|
||||
assert_eq!(normalize_clipboard_text("作品号 PZ-1"), "作品号 PZ-1");
|
||||
assert_eq!(
|
||||
normalize_clipboard_text(&"a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(),
|
||||
CLIPBOARD_TEXT_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::host_bridge::capabilities::capabilities;
|
||||
use crate::host_bridge::clipboard::{read_desktop_clipboard_text, write_desktop_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,
|
||||
write_export_bytes_file,
|
||||
write_export_text_file,
|
||||
write_export_bytes_file, write_export_text_file,
|
||||
};
|
||||
use crate::host_bridge::protocol::{
|
||||
failed, ok, required_string_payload, validate_request, HostBridgeRequest, HostBridgeResponse,
|
||||
@@ -16,14 +16,12 @@ use crate::shell::webview::{
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_notification::{NotificationExt, PermissionState};
|
||||
|
||||
const BADGE_COUNT_MAX: i64 = 99999;
|
||||
const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80;
|
||||
const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240;
|
||||
const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000;
|
||||
const WINDOW_TITLE_MAX_LENGTH: usize = 80;
|
||||
|
||||
fn normalize_window_title(raw_title: &str) -> Option<String> {
|
||||
@@ -60,10 +58,6 @@ fn badge_count_payload(request: &HostBridgeRequest) -> Result<Option<i64>, HostB
|
||||
Ok(if count == 0 { None } else { Some(count) })
|
||||
}
|
||||
|
||||
fn normalize_clipboard_text(text: &str) -> String {
|
||||
text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect()
|
||||
}
|
||||
|
||||
fn normalize_plain_text(
|
||||
value: Option<&str>,
|
||||
max_length: usize,
|
||||
@@ -240,23 +234,23 @@ pub(super) async fn execute_host_bridge_request(
|
||||
},
|
||||
"clipboard.writeText" => {
|
||||
let text = match required_string_payload(&request, "text") {
|
||||
Ok(text) => normalize_clipboard_text(text),
|
||||
Ok(text) => text,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
match app.clipboard().write_text(text) {
|
||||
match write_desktop_clipboard_text(&app, text) {
|
||||
Ok(()) => ok(request.id, json!(true)),
|
||||
Err(error) => failed(request.id, "host_error", error.to_string()),
|
||||
Err(error) => failed(request.id, "host_error", error),
|
||||
}
|
||||
}
|
||||
"clipboard.readText" => match app.clipboard().read_text() {
|
||||
"clipboard.readText" => match read_desktop_clipboard_text(&app) {
|
||||
Ok(text) => ok(
|
||||
request.id,
|
||||
json!({
|
||||
"text": normalize_clipboard_text(&text),
|
||||
"text": text,
|
||||
}),
|
||||
),
|
||||
Err(error) => failed(request.id, "host_error", error.to_string()),
|
||||
Err(error) => failed(request.id, "host_error", error),
|
||||
},
|
||||
"file.exportText" => {
|
||||
let (file_name, content) = match export_text_payload(&request) {
|
||||
@@ -319,7 +313,10 @@ pub(super) async fn execute_host_bridge_request(
|
||||
let file_path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("Document", &["txt", "md", "markdown", "csv", "json", "docx"])
|
||||
.add_filter(
|
||||
"Document",
|
||||
&["txt", "md", "markdown", "csv", "json", "docx"],
|
||||
)
|
||||
.blocking_pick_file();
|
||||
let Some(file_path) = file_path else {
|
||||
return failed(request.id, "cancelled", "file import cancelled");
|
||||
@@ -548,14 +545,14 @@ pub(super) async fn execute_host_bridge_request(
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
match app.clipboard().write_text(share_text) {
|
||||
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.to_string()),
|
||||
Err(error) => failed(request.id, "host_error", error),
|
||||
}
|
||||
}
|
||||
_ => resolve_host_bridge_request(request),
|
||||
@@ -674,18 +671,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_text_is_truncated_to_contract_limit() {
|
||||
assert_eq!(
|
||||
normalize_clipboard_text("作品号 PZ-1"),
|
||||
"作品号 PZ-1"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_clipboard_text(&"a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(),
|
||||
CLIPBOARD_TEXT_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_notification_payload_is_normalized() {
|
||||
let mut request = request("notification.showLocal");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod capabilities;
|
||||
mod clipboard;
|
||||
mod dispatch;
|
||||
pub(crate) mod files;
|
||||
pub(crate) mod protocol;
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
- 2026-06-19 草稿生成 HostBridge 消费门禁:`PlatformEntryFlowShellImpl` 只负责派生草稿通知和未读数量,实际宿主同步经 `platformHostBridgeSync.ts` 调用 `showHostLocalNotification` / `setHostAppBadgeCount`;`npm run check:native-shells` 必须运行该同步层的真实 Tauri transport 测试,并继续覆盖通知模型、未读计数模型、音频导入和文档导入等 H5 HostBridge 消费测试。
|
||||
- 2026-06-18 剪贴板读取能力:新增 `clipboard.readText` HostBridge capability,H5 只能读取纯文本结果,契约限制返回文本最多 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-18 文本文件导入能力:新增 `file.importText` HostBridge capability,H5 统一通过 `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` 时继续使用文本导入兜底。
|
||||
- 2026-06-18 Tauri 系统托盘:桌面壳启用真实 OS 托盘并复用品牌图标,托盘菜单只执行显示主窗口、刷新主窗口和退出应用,左键点击托盘图标恢复并聚焦主窗口;该能力归桌面壳自身,不进入 HostBridge capability,不向 H5 暴露托盘、菜单、shell 或任意窗口控制 API。托盘注册成功时主窗口关闭按钮只隐藏到托盘,必须通过托盘“退出”结束应用;托盘注册失败不得阻断主窗口启动,也不得拦截关闭,避免窗口消失后无法恢复。`check:native-shells` 和 Tauri cargo test 覆盖托盘配置、菜单动作映射和关闭策略。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -293,6 +293,7 @@ const expectedMobileShellFiles = [
|
||||
];
|
||||
const expectedDesktopHostBridgeRustFiles = [
|
||||
'capabilities.rs',
|
||||
'clipboard.rs',
|
||||
'dispatch.rs',
|
||||
'files.rs',
|
||||
'mod.rs',
|
||||
|
||||
Reference in New Issue
Block a user