收口桌面窗口标题桥接边界
将 Tauri 窗口标题 HostBridge 处理收口到 title 模块 让桌面 dispatch 只委托窗口标题请求 同步桌面壳结构门禁、架构文档和共享记忆
This commit is contained in:
@@ -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 错误回传给 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 不得本地重声明。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -308,6 +308,7 @@ const expectedDesktopHostBridgeRustFiles = [
|
||||
'notifications.rs',
|
||||
'protocol.rs',
|
||||
'share.rs',
|
||||
'title.rs',
|
||||
];
|
||||
const expectedDesktopShellRustFiles = [
|
||||
'deep_link.rs',
|
||||
|
||||
Reference in New Issue
Block a user