收口桌面文件桥接执行边界

将 Tauri 文件导入导出执行编排收口到 files 模块

让桌面 dispatch 只委托文件能力请求

同步桌面壳文件边界门禁、架构文档和共享记忆
This commit is contained in:
2026-06-19 22:54:54 +08:00
parent dfeea9e93c
commit ff64021c20
6 changed files with 324 additions and 218 deletions
+79 -10
View File
@@ -86,6 +86,14 @@ const desktopHostBridgeDispatchSource = fs.readFileSync(
desktopHostBridgeDispatchPath,
'utf8',
);
const desktopHostBridgeFilesPath = new URL(
'../src-tauri/src/host_bridge/files.rs',
import.meta.url,
);
const desktopHostBridgeFilesSource = fs.readFileSync(
desktopHostBridgeFilesPath,
'utf8',
);
const desktopHostBridgeNavigationPath = new URL(
'../src-tauri/src/host_bridge/navigation.rs',
import.meta.url,
@@ -951,7 +959,7 @@ function extractDesktopHandledMethods(source) {
}
function extractDesktopHostBridgeMethodBody(source, method) {
const methodStart = source.indexOf(`"${method}" => {`);
const methodStart = source.indexOf(`"${method}" =>`);
if (methodStart < 0) {
throw new Error(`desktop shell HostBridge missing method ${method}`);
}
@@ -968,29 +976,65 @@ function extractDesktopHostBridgeMethodBody(source, method) {
);
}
function extractDesktopDialogFilter(source, method, filterLabel) {
const methodBody = extractDesktopHostBridgeMethodBody(source, method);
const match = methodBody.match(
function extractFunctionBody(source, functionName) {
const functionStart = source.search(
new RegExp(`(?:pub\\(crate\\)\\s+)?(?:async\\s+)?fn\\s+${functionName}\\s*\\(`),
);
if (functionStart < 0) {
throw new Error(`unable to read Rust function ${functionName}`);
}
const bodyStart = source.indexOf('{', functionStart);
if (bodyStart < 0) {
throw new Error(`unable to read Rust function body ${functionName}`);
}
let depth = 0;
for (let index = bodyStart; index < source.length; index += 1) {
const character = source[index];
if (character === '{') {
depth += 1;
} else if (character === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(bodyStart, index + 1);
}
}
}
throw new Error(`unable to read Rust function body ${functionName}`);
}
function extractDesktopDialogFilter(source, ownerName, filterLabel) {
const ownerBody = extractFunctionBody(source, ownerName);
const match = ownerBody.match(
new RegExp(`\\.add_filter\\(\\s*"${filterLabel}",\\s*&\\[([^\\]]*)\\]\\s*,?\\s*\\)`),
);
if (!match) {
throw new Error(
`desktop shell ${method} must use a ${filterLabel} system dialog filter`,
`desktop shell ${ownerName} must use a ${filterLabel} system dialog filter`,
);
}
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function assertDesktopDialogBoundary(method, filterLabel, expected, action) {
function assertDesktopDialogBoundary(method, functionName, filterLabel, expected, action) {
const methodBody = extractDesktopHostBridgeMethodBody(
desktopHostBridgeDispatchSource,
method,
);
if (!methodBody.includes(`${functionName}(&app, &request).await`)) {
throw new Error(`desktop shell ${method} must delegate to files module`);
}
const fileFunctionBody = extractFunctionBody(
desktopHostBridgeFilesSource,
functionName,
);
assertSameList(
extractDesktopDialogFilter(
desktopHostBridgeDispatchSource,
method,
desktopHostBridgeFilesSource,
functionName,
filterLabel,
),
expected,
@@ -1001,10 +1045,10 @@ function assertDesktopDialogBoundary(method, filterLabel, expected, action) {
action === 'save' ? '.blocking_save_file()' : '.blocking_pick_file()';
const blockedDialogAction =
action === 'save' ? '.blocking_pick_file()' : '.blocking_save_file()';
if (!methodBody.includes(requiredDialogAction)) {
if (!fileFunctionBody.includes(requiredDialogAction)) {
throw new Error(`desktop shell ${method} must use ${requiredDialogAction}`);
}
if (methodBody.includes(blockedDialogAction)) {
if (fileFunctionBody.includes(blockedDialogAction)) {
throw new Error(`desktop shell ${method} must not use ${blockedDialogAction}`);
}
}
@@ -2327,46 +2371,71 @@ for (const staleTimeoutBoundary of [
assertDesktopDialogBoundary(
'file.exportText',
'export_desktop_host_bridge_text_file',
'Text',
['txt', 'json', 'md', 'csv'],
'save',
);
assertDesktopDialogBoundary(
'file.importText',
'import_desktop_host_bridge_text_file',
'Text',
['txt', 'md', 'markdown', 'csv', 'json'],
'pick',
);
assertDesktopDialogBoundary(
'file.importDocument',
'import_desktop_host_bridge_document_file',
'Document',
['txt', 'md', 'markdown', 'csv', 'json', 'docx'],
'pick',
);
assertDesktopDialogBoundary(
'file.exportImage',
'export_desktop_host_bridge_image_file',
'Image',
['png', 'jpg', 'jpeg', 'webp'],
'save',
);
assertDesktopDialogBoundary(
'file.importImage',
'import_desktop_host_bridge_image_file',
'Image',
['png', 'jpg', 'jpeg', 'webp'],
'pick',
);
assertDesktopDialogBoundary(
'file.importAudio',
'import_desktop_host_bridge_audio_file',
'Audio',
['mp3', 'm4a', 'mp4', 'wav', 'ogg', 'webm'],
'pick',
);
assertDesktopDialogBoundary(
'file.exportAudio',
'export_desktop_host_bridge_audio_file',
'Audio',
['mp3', 'm4a', 'wav', 'ogg', 'webm'],
'save',
);
for (const snippet of [
'.dialog()',
'blocking_save_file',
'blocking_pick_file',
'export_text_payload',
'import_text_file_payload',
'import_document_file_payload',
'export_image_payload',
'import_image_file_payload',
'import_audio_file_payload',
'export_audio_payload',
'write_export_text_file',
'write_export_bytes_file',
]) {
if (desktopHostBridgeDispatchSource.includes(snippet)) {
throw new Error(`desktop shell dispatch must delegate file boundary instead of ${snippet}`);
}
}
assertSameList(
capability.windows ?? [],
@@ -5,9 +5,10 @@ use crate::host_bridge::clipboard::{
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,
import_document_file_payload, import_image_file_payload, import_text_file_payload,
write_export_bytes_file, write_export_text_file,
export_desktop_host_bridge_audio_file, export_desktop_host_bridge_image_file,
export_desktop_host_bridge_text_file, import_desktop_host_bridge_audio_file,
import_desktop_host_bridge_document_file, import_desktop_host_bridge_image_file,
import_desktop_host_bridge_text_file,
};
use crate::host_bridge::navigation::{
open_desktop_host_bridge_external_url, open_desktop_host_bridge_native_page,
@@ -25,7 +26,6 @@ use crate::host_bridge::share::{
use crate::host_bridge::title::set_desktop_host_bridge_window_title;
use crate::shell::webview::desktop_platform;
use serde_json::json;
use tauri_plugin_dialog::DialogExt;
fn desktop_runtime() -> HostBridgeRuntime {
HostBridgeRuntime {
@@ -67,202 +67,13 @@ pub(super) async fn execute_host_bridge_request(
"app.reloadWebView" => reload_desktop_host_bridge_webview(&app, &request),
"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,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Text", &["txt", "json", "md", "csv"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return failed(request.id, "cancelled", "file export cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content))
.await;
let bytes = match export_result {
Ok(Ok(bytes)) => bytes,
Ok(Err(error)) => return failed(request.id, "host_error", error),
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
ok(
request.id,
json!({
"action": "saved",
"fileName": file_name,
"bytes": bytes,
}),
)
}
"file.importText" => {
let file_path = app
.dialog()
.file()
.add_filter("Text", &["txt", "md", "markdown", "csv", "json"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return failed(request.id, "cancelled", "file import cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await;
match import_result {
Ok(Ok(payload)) => ok(request.id, payload),
Ok(Err(error)) => failed(request.id, "invalid_request", error),
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"file.importDocument" => {
let file_path = app
.dialog()
.file()
.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");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path))
.await;
match import_result {
Ok(Ok(payload)) => ok(request.id, payload),
Ok(Err(error)) => failed(request.id, "invalid_request", error),
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"file.exportImage" => {
let (file_name, bytes) = match export_image_payload(&request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return failed(request.id, "cancelled", "file export cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes))
.await;
let byte_count = match export_result {
Ok(Ok(byte_count)) => byte_count,
Ok(Err(error)) => return failed(request.id, "host_error", error),
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
ok(
request.id,
json!({
"action": "saved",
"fileName": file_name,
"bytes": byte_count,
}),
)
}
"file.importImage" => {
let file_path = app
.dialog()
.file()
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return failed(request.id, "cancelled", "file import cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let import_result = tauri::async_runtime::spawn_blocking(move || {
import_image_file_payload(path, "selected", None)
})
.await;
match import_result {
Ok(Ok(payload)) => ok(request.id, payload),
Ok(Err(error)) => failed(request.id, "invalid_request", error),
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"file.importAudio" => {
let file_path = app
.dialog()
.file()
.add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return failed(request.id, "cancelled", "file import cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await;
match import_result {
Ok(Ok(payload)) => ok(request.id, payload),
Ok(Err(error)) => failed(request.id, "invalid_request", error),
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"file.exportAudio" => {
let (file_name, bytes) = match export_audio_payload(&request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return failed(request.id, "cancelled", "file export cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes))
.await;
let byte_count = match export_result {
Ok(Ok(byte_count)) => byte_count,
Ok(Err(error)) => return failed(request.id, "host_error", error),
Err(error) => return failed(request.id, "host_error", error.to_string()),
};
ok(
request.id,
json!({
"action": "saved",
"fileName": file_name,
"bytes": byte_count,
}),
)
}
"file.exportText" => export_desktop_host_bridge_text_file(&app, &request).await,
"file.importText" => import_desktop_host_bridge_text_file(&app, &request).await,
"file.importDocument" => import_desktop_host_bridge_document_file(&app, &request).await,
"file.exportImage" => export_desktop_host_bridge_image_file(&app, &request).await,
"file.importImage" => import_desktop_host_bridge_image_file(&app, &request).await,
"file.importAudio" => import_desktop_host_bridge_audio_file(&app, &request).await,
"file.exportAudio" => export_desktop_host_bridge_audio_file(&app, &request).await,
"app.setTitle" => set_desktop_host_bridge_window_title(&app, &request),
"app.setBadgeCount" => match set_desktop_app_badge_count(&app, &request) {
Ok(()) => ok(request.id, json!(true)),
@@ -1,8 +1,9 @@
use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse};
use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use tauri_plugin_dialog::DialogExt;
pub(crate) const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024;
const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
@@ -398,9 +399,8 @@ pub(crate) fn export_image_payload(
"image exceeds file export size limit",
));
}
ensure_image_bytes_match_mime_type(&bytes, mime_type).map_err(|message| {
failed(request.id.clone(), "invalid_request", message)
})?;
ensure_image_bytes_match_mime_type(&bytes, mime_type)
.map_err(|message| failed(request.id.clone(), "invalid_request", message))?;
let file_name = payload
.get("fileName")
@@ -465,9 +465,8 @@ pub(crate) fn export_audio_payload(
"audio exceeds file export size limit",
));
}
ensure_audio_bytes_match_mime_type(&bytes, mime_type).map_err(|message| {
failed(request.id.clone(), "invalid_request", message)
})?;
ensure_audio_bytes_match_mime_type(&bytes, mime_type)
.map_err(|message| failed(request.id.clone(), "invalid_request", message))?;
let file_name = payload
.get("fileName")
@@ -564,6 +563,226 @@ pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result<Value, String>
}))
}
pub(crate) async fn export_desktop_host_bridge_text_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let (file_name, content) = match export_text_payload(request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Text", &["txt", "json", "md", "csv"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return failed(request.id.clone(), "cancelled", "file export cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content)).await;
let bytes = match export_result {
Ok(Ok(bytes)) => bytes,
Ok(Err(error)) => return failed(request.id.clone(), "host_error", error),
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
ok(
request.id.clone(),
json!({
"action": "saved",
"fileName": file_name,
"bytes": bytes,
}),
)
}
pub(crate) async fn import_desktop_host_bridge_text_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let file_path = app
.dialog()
.file()
.add_filter("Text", &["txt", "md", "markdown", "csv", "json"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return failed(request.id.clone(), "cancelled", "file import cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await;
match import_result {
Ok(Ok(payload)) => ok(request.id.clone(), payload),
Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error),
Err(error) => failed(request.id.clone(), "host_error", error.to_string()),
}
}
pub(crate) async fn import_desktop_host_bridge_document_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let file_path = app
.dialog()
.file()
.add_filter(
"Document",
&["txt", "md", "markdown", "csv", "json", "docx"],
)
.blocking_pick_file();
let Some(file_path) = file_path else {
return failed(request.id.clone(), "cancelled", "file import cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path)).await;
match import_result {
Ok(Ok(payload)) => ok(request.id.clone(), payload),
Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error),
Err(error) => failed(request.id.clone(), "host_error", error.to_string()),
}
}
pub(crate) async fn export_desktop_host_bridge_image_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let (file_name, bytes) = match export_image_payload(request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return failed(request.id.clone(), "cancelled", "file export cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await;
let byte_count = match export_result {
Ok(Ok(byte_count)) => byte_count,
Ok(Err(error)) => return failed(request.id.clone(), "host_error", error),
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
ok(
request.id.clone(),
json!({
"action": "saved",
"fileName": file_name,
"bytes": byte_count,
}),
)
}
pub(crate) async fn import_desktop_host_bridge_image_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let file_path = app
.dialog()
.file()
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return failed(request.id.clone(), "cancelled", "file import cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
let import_result = tauri::async_runtime::spawn_blocking(move || {
import_image_file_payload(path, "selected", None)
})
.await;
match import_result {
Ok(Ok(payload)) => ok(request.id.clone(), payload),
Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error),
Err(error) => failed(request.id.clone(), "host_error", error.to_string()),
}
}
pub(crate) async fn import_desktop_host_bridge_audio_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let file_path = app
.dialog()
.file()
.add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"])
.blocking_pick_file();
let Some(file_path) = file_path else {
return failed(request.id.clone(), "cancelled", "file import cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
let import_result =
tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await;
match import_result {
Ok(Ok(payload)) => ok(request.id.clone(), payload),
Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error),
Err(error) => failed(request.id.clone(), "host_error", error.to_string()),
}
}
pub(crate) async fn export_desktop_host_bridge_audio_file(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let (file_name, bytes) = match export_audio_payload(request) {
Ok(payload) => payload,
Err(response) => return response,
};
let file_path = app
.dialog()
.file()
.add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"])
.set_file_name(file_name.clone())
.blocking_save_file();
let Some(file_path) = file_path else {
return failed(request.id.clone(), "cancelled", "file export cancelled");
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
let export_result =
tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await;
let byte_count = match export_result {
Ok(Ok(byte_count)) => byte_count,
Ok(Err(error)) => return failed(request.id.clone(), "host_error", error),
Err(error) => return failed(request.id.clone(), "host_error", error.to_string()),
};
ok(
request.id.clone(),
json!({
"action": "saved",
"fileName": file_name,
"bytes": byte_count,
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1108,7 +1327,10 @@ mod tests {
assert_eq!(detect_image_mime_type(&webp_bytes()), Some("image/webp"));
assert_eq!(detect_image_mime_type(b"text"), None);
assert_eq!(detect_audio_mime_type(&mp3_bytes()), Some("audio/mpeg"));
assert_eq!(detect_audio_mime_type(&mp4_audio_bytes()), Some("audio/mp4"));
assert_eq!(
detect_audio_mime_type(&mp4_audio_bytes()),
Some("audio/mp4")
);
assert_eq!(detect_audio_mime_type(&wav_bytes()), Some("audio/wav"));
assert_eq!(detect_audio_mime_type(&ogg_bytes()), Some("audio/ogg"));
assert_eq!(detect_audio_mime_type(&webm_bytes()), Some("audio/webm"));
@@ -31,6 +31,7 @@
- 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 桌面壳窗口标题桥接边界:Tauri `app.setTitle` 的 payload 校验、非空 / 控制字符拒绝、80 字符截断和主窗口 `set_title` 调用统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs``dispatch.rs` 只负责委托 `set_desktop_host_bridge_window_title(...)`。桌面壳配置检查和根级结构门禁会覆盖 `title.rs` 文件清单、共享标题长度镜像和 dispatch 委托关系。
- 2026-06-19 桌面壳文件桥接执行边界:Tauri `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.importAudio` / `file.exportAudio` 的系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs``dispatch.rs` 只负责按 method 委托 `export_desktop_host_bridge_*_file(...)` / `import_desktop_host_bridge_*_file(...)`。桌面壳配置检查会拒绝分发层直接调用 `.dialog()``blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper,避免文件访问边界重新散落。
- 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 不得本地重声明。
@@ -519,6 +519,7 @@ GameBridge 禁止:
- 移动端接入系统分享、推送、原生登录和渠道支付。
- 移动端和桌面端的自动更新、崩溃上报、analytics、渠道分发、原生登录和渠道支付都必须等真实 SDK、后端契约、发布流程和隐私口径确定后逐项接入;文件导出、图片拖拽导入、系统托盘、即时本地通知和系统分享已按真实宿主能力逐项接入。
- Tauri 桌面壳的文件导入导出执行边界统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`,系统文件对话框过滤器、用户取消语义、路径转换、异步读写和 HostBridge 响应都由文件模块负责;`dispatch.rs` 只按 method 委托文件模块。
- 所有新增能力先更新 HostBridge 契约和测试,再落壳实现。
### Phase 5AI H5 sandbox
@@ -81,6 +81,8 @@ HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg``audio/mp4``audio/wav``audio/ogg``audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportAudioResult()` 复核文件名、MIME、base64 和字节数。H5 的通用音频输入面板 `CreativeAudioInputPanel``native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;视觉小说结果页音乐和环境音素材上传同样优先调用宿主音频导入,再把返回副本转换成浏览器 `File` 后继续交给 `uploadVisualNovelAsset` 上传和场景音频字段写回链路。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIMEH5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportAudioPayload()` 预校验文件名、MIME、base64 和 20 MiB 上限,Expo 与 Tauri 壳仍必须二次校验真实字节与 MIME。Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob``fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。
Tauri 桌面壳的文件能力边界统一在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 内完成:该模块同时持有文件 payload 校验、系统文件对话框过滤器、用户取消语义、路径转换、异步读写和 HostBridge 响应归一;`dispatch.rs` 只按 method 委托文件模块,不直接调用 `.dialog()``blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper。
## 迁移顺序
1. 新增 `src/services/host-bridge/`,沉淀宿主运行态识别和微信小程序 JS SDK 加载,并暴露通用 HostBridge 能力接口。