收口桌面窗口标题桥接边界

将 Tauri 窗口标题 HostBridge 处理收口到 title 模块

让桌面 dispatch 只委托窗口标题请求

同步桌面壳结构门禁、架构文档和共享记忆
This commit is contained in:
2026-06-19 22:45:02 +08:00
parent d53bd66e99
commit dfeea9e93c
8 changed files with 106 additions and 57 deletions
+32 -1
View File
@@ -118,6 +118,14 @@ const desktopHostBridgeShareSource = fs.readFileSync(
desktopHostBridgeSharePath,
'utf8',
);
const desktopHostBridgeTitlePath = new URL(
'../src-tauri/src/host_bridge/title.rs',
import.meta.url,
);
const desktopHostBridgeTitleSource = fs.readFileSync(
desktopHostBridgeTitlePath,
'utf8',
);
const desktopShellUrlPath = new URL(
'../src-tauri/src/shell/url.rs',
import.meta.url,
@@ -237,6 +245,7 @@ const expectedDesktopHostBridgeRustFiles = [
'notifications.rs',
'protocol.rs',
'share.rs',
'title.rs',
];
const expectedDesktopShellRustFiles = [
'deep_link.rs',
@@ -1323,7 +1332,7 @@ const desktopHostBridgePayloadLimits = {
'BADGE_COUNT_MAX',
),
HOST_BRIDGE_APP_TITLE_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
desktopHostBridgeTitleSource,
'WINDOW_TITLE_MAX_LENGTH',
),
HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractRustNumberConst(
@@ -1934,6 +1943,7 @@ const expectedRustHostBridgeFiles = [
'notifications.rs',
'protocol.rs',
'share.rs',
'title.rs',
];
const expectedRustShellFiles = [
'deep_link.rs',
@@ -2211,6 +2221,27 @@ for (const snippet of [
if (!desktopHostBridgeBadgeSource.match(/window\s*\.\s*set_badge_count\s*\(\s*count\s*\)/)) {
throw new Error('desktop shell badge module must call window.set_badge_count(count)');
}
if (
!desktopHostBridgeDispatchSource.includes(
'set_desktop_host_bridge_window_title(&app, &request)',
) ||
desktopHostBridgeDispatchSource.includes('WINDOW_TITLE_MAX_LENGTH') ||
desktopHostBridgeDispatchSource.includes('required_string_payload(&request, "title")') ||
desktopHostBridgeDispatchSource.includes('window.set_title')
) {
throw new Error('desktop shell title HostBridge method must delegate to title module');
}
for (const snippet of [
'WINDOW_TITLE_MAX_LENGTH',
'normalize_window_title',
'required_string_payload(request, "title")',
'window.set_title(&title)',
'"title is required"',
]) {
if (!desktopHostBridgeTitleSource.includes(snippet)) {
throw new Error(`desktop shell title module is missing ${snippet}`);
}
}
if (
!desktopHostBridgeDispatchSource.includes(
'show_desktop_local_notification(&app, &request)',
@@ -16,28 +16,17 @@ use crate::host_bridge::navigation::{
use crate::host_bridge::network::resolve_desktop_host_bridge_network_status;
use crate::host_bridge::notifications::show_desktop_local_notification;
use crate::host_bridge::protocol::{
failed, ok, required_string_payload, validate_request, HostBridgeRequest, HostBridgeResponse,
HostBridgeRuntime, HOST_BRIDGE_VERSION,
failed, ok, validate_request, HostBridgeRequest, HostBridgeResponse, HostBridgeRuntime,
HOST_BRIDGE_VERSION,
};
use crate::host_bridge::share::{
open_desktop_host_bridge_share, set_desktop_host_bridge_share_target,
};
use crate::host_bridge::title::set_desktop_host_bridge_window_title;
use crate::shell::webview::desktop_platform;
use serde_json::json;
use tauri::Manager;
use tauri_plugin_dialog::DialogExt;
const WINDOW_TITLE_MAX_LENGTH: usize = 80;
fn normalize_window_title(raw_title: &str) -> Option<String> {
let title = raw_title.trim();
if title.is_empty() || title.chars().any(char::is_control) {
return None;
}
Some(title.chars().take(WINDOW_TITLE_MAX_LENGTH).collect())
}
fn desktop_runtime() -> HostBridgeRuntime {
HostBridgeRuntime {
shell: "tauri_desktop",
@@ -274,23 +263,7 @@ pub(super) async fn execute_host_bridge_request(
}),
)
}
"app.setTitle" => {
let title = match required_string_payload(&request, "title")
.ok()
.and_then(normalize_window_title)
{
Some(title) => title,
None => return failed(request.id, "invalid_request", "title is required"),
};
match app.get_webview_window("main") {
Some(window) => match window.set_title(&title) {
Ok(()) => ok(request.id, json!(true)),
Err(error) => failed(request.id, "host_error", error.to_string()),
},
None => failed(request.id, "host_error", "main window not found"),
}
}
"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)),
Err(response) => response,
@@ -420,23 +393,4 @@ mod tests {
assert!(error.message.contains(method));
}
}
#[test]
fn window_title_normalization_requires_visible_text() {
assert_eq!(
normalize_window_title(" Genarrative "),
Some("Genarrative".to_string())
);
assert_eq!(normalize_window_title(""), None);
assert_eq!(normalize_window_title("Genarrative\nDev"), None);
let long_title = "".repeat(120);
assert_eq!(
normalize_window_title(&long_title)
.expect("truncated title")
.chars()
.count(),
80
);
}
}
@@ -9,6 +9,7 @@ mod network;
mod notifications;
pub(crate) mod protocol;
mod share;
mod title;
pub(crate) use protocol::HostBridgeReplayState;
pub(crate) use share::DesktopShareState;
@@ -0,0 +1,61 @@
use crate::host_bridge::protocol::{
failed, ok, required_string_payload, HostBridgeRequest, HostBridgeResponse,
};
use serde_json::json;
use tauri::Manager;
const WINDOW_TITLE_MAX_LENGTH: usize = 80;
fn normalize_window_title(raw_title: &str) -> Option<String> {
let title = raw_title.trim();
if title.is_empty() || title.chars().any(char::is_control) {
return None;
}
Some(title.chars().take(WINDOW_TITLE_MAX_LENGTH).collect())
}
pub(crate) fn set_desktop_host_bridge_window_title(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> HostBridgeResponse {
let title = match required_string_payload(request, "title")
.ok()
.and_then(normalize_window_title)
{
Some(title) => title,
None => return failed(request.id.clone(), "invalid_request", "title is required"),
};
match app.get_webview_window("main") {
Some(window) => match window.set_title(&title) {
Ok(()) => ok(request.id.clone(), json!(true)),
Err(error) => failed(request.id.clone(), "host_error", error.to_string()),
},
None => failed(request.id.clone(), "host_error", "main window not found"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_title_normalization_requires_visible_text() {
assert_eq!(
normalize_window_title(" Genarrative "),
Some("Genarrative".to_string())
);
assert_eq!(normalize_window_title(""), None);
assert_eq!(normalize_window_title("Genarrative\nDev"), None);
let long_title = "".repeat(120);
assert_eq!(
normalize_window_title(&long_title)
.expect("truncated title")
.chars()
.count(),
80
);
}
}
@@ -30,6 +30,7 @@
- 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 桌面壳窗口标题桥接边界: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 桌面壳外链打开 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 不得本地重声明。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -308,6 +308,7 @@ const expectedDesktopHostBridgeRustFiles = [
'notifications.rs',
'protocol.rs',
'share.rs',
'title.rs',
];
const expectedDesktopShellRustFiles = [
'deep_link.rs',