收口桌面通知边界
将 Tauri 本地通知发送收口到 notifications 模块 让桌面 HostBridge dispatch 只负责通知 payload 分发 同步原生壳结构门禁和架构文档清单
This commit is contained in:
@@ -70,6 +70,14 @@ const desktopHostBridgeDispatchSource = fs.readFileSync(
|
||||
desktopHostBridgeDispatchPath,
|
||||
'utf8',
|
||||
);
|
||||
const desktopHostBridgeNotificationsPath = new URL(
|
||||
'../src-tauri/src/host_bridge/notifications.rs',
|
||||
import.meta.url,
|
||||
);
|
||||
const desktopHostBridgeNotificationsSource = fs.readFileSync(
|
||||
desktopHostBridgeNotificationsPath,
|
||||
'utf8',
|
||||
);
|
||||
const desktopHostBridgeSharePath = new URL(
|
||||
'../src-tauri/src/host_bridge/share.rs',
|
||||
import.meta.url,
|
||||
@@ -190,6 +198,7 @@ const expectedDesktopHostBridgeRustFiles = [
|
||||
'dispatch.rs',
|
||||
'files.rs',
|
||||
'mod.rs',
|
||||
'notifications.rs',
|
||||
'protocol.rs',
|
||||
'share.rs',
|
||||
];
|
||||
@@ -1286,11 +1295,11 @@ const desktopHostBridgePayloadLimits = {
|
||||
'CLIPBOARD_TEXT_MAX_LENGTH',
|
||||
),
|
||||
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractRustNumberConst(
|
||||
rustHostSource,
|
||||
desktopHostBridgeNotificationsSource,
|
||||
'LOCAL_NOTIFICATION_TITLE_MAX_LENGTH',
|
||||
),
|
||||
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractRustNumberConst(
|
||||
rustHostSource,
|
||||
desktopHostBridgeNotificationsSource,
|
||||
'LOCAL_NOTIFICATION_BODY_MAX_LENGTH',
|
||||
),
|
||||
HOST_BRIDGE_FILE_NAME_MAX_LENGTH: extractRustNumberConst(
|
||||
@@ -1799,6 +1808,7 @@ const expectedRustHostBridgeFiles = [
|
||||
'dispatch.rs',
|
||||
'files.rs',
|
||||
'mod.rs',
|
||||
'notifications.rs',
|
||||
'protocol.rs',
|
||||
'share.rs',
|
||||
];
|
||||
@@ -1904,8 +1914,6 @@ const requiredRustHostSnippets = [
|
||||
'PermissionState::Granted',
|
||||
'PermissionState::Denied',
|
||||
'PermissionState::Prompt | PermissionState::PromptWithRationale',
|
||||
'notification_manager.permission_state()',
|
||||
'notification_manager.request_permission()',
|
||||
'"notification permission denied"',
|
||||
'"copied_to_clipboard"',
|
||||
'"file export cancelled"',
|
||||
@@ -2040,6 +2048,46 @@ assertSameList(
|
||||
if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) {
|
||||
throw new Error('H5 native app HostBridge must use HOST_BRIDGE_TAURI_COMMAND');
|
||||
}
|
||||
if (
|
||||
!desktopHostBridgeDispatchSource.includes(
|
||||
'show_desktop_local_notification(&app, &request)',
|
||||
) ||
|
||||
desktopHostBridgeDispatchSource.includes('app.notification()') ||
|
||||
desktopHostBridgeDispatchSource.includes('NotificationExt') ||
|
||||
desktopHostBridgeDispatchSource.includes('PermissionState')
|
||||
) {
|
||||
throw new Error(
|
||||
'desktop shell notification HostBridge method must delegate to notifications module',
|
||||
);
|
||||
}
|
||||
for (const snippet of [
|
||||
'tauri_plugin_notification::{NotificationExt, PermissionState}',
|
||||
'LOCAL_NOTIFICATION_TITLE_MAX_LENGTH',
|
||||
'LOCAL_NOTIFICATION_BODY_MAX_LENGTH',
|
||||
'DesktopNotificationPermissionAction',
|
||||
'desktop_notification_permission_action',
|
||||
'PermissionState::Granted',
|
||||
'PermissionState::Denied',
|
||||
'PermissionState::Prompt | PermissionState::PromptWithRationale',
|
||||
'"notification permission denied"',
|
||||
'app.notification().builder()',
|
||||
]) {
|
||||
if (!desktopHostBridgeNotificationsSource.includes(snippet)) {
|
||||
throw new Error(`desktop shell notification module is missing ${snippet}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!desktopHostBridgeNotificationsSource.match(
|
||||
/notification_manager\s*\.\s*permission_state\s*\(\s*\)/,
|
||||
) ||
|
||||
!desktopHostBridgeNotificationsSource.match(
|
||||
/notification_manager\s*\.\s*request_permission\s*\(\s*\)/,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'desktop shell notification module must check and request notification permission',
|
||||
);
|
||||
}
|
||||
if (!h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,')) {
|
||||
throw new Error('H5 HostBridge facade must import shared runtime refresh timeout');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::host_bridge::files::{
|
||||
import_document_file_payload, import_image_file_payload, import_text_file_payload,
|
||||
write_export_bytes_file, write_export_text_file,
|
||||
};
|
||||
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,
|
||||
@@ -17,11 +18,8 @@ use crate::shell::webview::{
|
||||
use serde_json::{json, Value};
|
||||
use tauri::Manager;
|
||||
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 WINDOW_TITLE_MAX_LENGTH: usize = 80;
|
||||
|
||||
fn normalize_window_title(raw_title: &str) -> Option<String> {
|
||||
@@ -58,84 +56,6 @@ fn badge_count_payload(request: &HostBridgeRequest) -> Result<Option<i64>, HostB
|
||||
Ok(if count == 0 { None } else { Some(count) })
|
||||
}
|
||||
|
||||
fn normalize_plain_text(
|
||||
value: Option<&str>,
|
||||
max_length: usize,
|
||||
required: bool,
|
||||
) -> Option<Option<String>> {
|
||||
let Some(value) = value else {
|
||||
return if required { None } else { Some(None) };
|
||||
};
|
||||
if value.chars().any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if text.is_empty() {
|
||||
return if required { None } else { Some(None) };
|
||||
}
|
||||
|
||||
Some(Some(text.chars().take(max_length).collect()))
|
||||
}
|
||||
|
||||
fn local_notification_payload(
|
||||
request: &HostBridgeRequest,
|
||||
) -> Result<(String, Option<String>), HostBridgeResponse> {
|
||||
let payload = request
|
||||
.payload
|
||||
.as_ref()
|
||||
.ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?;
|
||||
let title = match normalize_plain_text(
|
||||
payload.get("title").and_then(Value::as_str),
|
||||
LOCAL_NOTIFICATION_TITLE_MAX_LENGTH,
|
||||
true,
|
||||
) {
|
||||
Some(Some(title)) => title,
|
||||
_ => {
|
||||
return Err(failed(
|
||||
request.id.clone(),
|
||||
"invalid_request",
|
||||
"title is required",
|
||||
))
|
||||
}
|
||||
};
|
||||
let body = match normalize_plain_text(
|
||||
payload.get("body").and_then(Value::as_str),
|
||||
LOCAL_NOTIFICATION_BODY_MAX_LENGTH,
|
||||
false,
|
||||
) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
return Err(failed(
|
||||
request.id.clone(),
|
||||
"invalid_request",
|
||||
"body is invalid",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok((title, body))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum DesktopNotificationPermissionAction {
|
||||
Show,
|
||||
Request,
|
||||
Reject,
|
||||
}
|
||||
|
||||
fn desktop_notification_permission_action(
|
||||
permission_state: PermissionState,
|
||||
) -> DesktopNotificationPermissionAction {
|
||||
match permission_state {
|
||||
PermissionState::Granted => DesktopNotificationPermissionAction::Show,
|
||||
PermissionState::Denied => DesktopNotificationPermissionAction::Reject,
|
||||
PermissionState::Prompt | PermissionState::PromptWithRationale => {
|
||||
DesktopNotificationPermissionAction::Request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn desktop_runtime() -> HostBridgeRuntime {
|
||||
HostBridgeRuntime {
|
||||
shell: "tauri_desktop",
|
||||
@@ -487,38 +407,10 @@ pub(super) async fn execute_host_bridge_request(
|
||||
Err(error) => failed(request.id, "host_error", error.to_string()),
|
||||
}
|
||||
}
|
||||
"notification.showLocal" => {
|
||||
let (title, body) = match local_notification_payload(&request) {
|
||||
Ok(payload) => payload,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let notification_manager = app.notification();
|
||||
let permission_state = match notification_manager.permission_state() {
|
||||
Ok(permission_state) => permission_state,
|
||||
Err(error) => return failed(request.id, "host_error", error.to_string()),
|
||||
};
|
||||
let mut permission_action = desktop_notification_permission_action(permission_state);
|
||||
if permission_action == DesktopNotificationPermissionAction::Request {
|
||||
let requested_state = match notification_manager.request_permission() {
|
||||
Ok(permission_state) => permission_state,
|
||||
Err(error) => return failed(request.id, "host_error", error.to_string()),
|
||||
};
|
||||
permission_action = desktop_notification_permission_action(requested_state);
|
||||
}
|
||||
if permission_action != DesktopNotificationPermissionAction::Show {
|
||||
return failed(request.id, "host_error", "notification permission denied");
|
||||
}
|
||||
|
||||
let mut notification = app.notification().builder().title(title);
|
||||
if let Some(body) = body {
|
||||
notification = notification.body(body);
|
||||
}
|
||||
|
||||
match notification.show() {
|
||||
Ok(()) => ok(request.id, json!(true)),
|
||||
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,
|
||||
},
|
||||
"share.setTarget" => {
|
||||
let target = request
|
||||
.payload
|
||||
@@ -671,61 +563,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_notification_payload_is_normalized() {
|
||||
let mut request = request("notification.showLocal");
|
||||
request.payload = Some(json!({
|
||||
"title": " 生成完成 ",
|
||||
"body": " 作品已准备好 可以试玩 "
|
||||
}));
|
||||
|
||||
let (title, body) = local_notification_payload(&request).expect("payload");
|
||||
|
||||
assert_eq!(title, "生成完成");
|
||||
assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_notification_payload_rejects_empty_and_control_text() {
|
||||
let mut empty = request("notification.showLocal");
|
||||
empty.payload = Some(json!({
|
||||
"title": " "
|
||||
}));
|
||||
|
||||
let response = local_notification_payload(&empty).expect_err("empty title");
|
||||
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
|
||||
let mut control = request("notification.showLocal");
|
||||
control.payload = Some(json!({
|
||||
"title": "生成\n完成"
|
||||
}));
|
||||
|
||||
let response = local_notification_payload(&control).expect_err("control title");
|
||||
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_notification_permission_state_controls_delivery() {
|
||||
assert_eq!(
|
||||
desktop_notification_permission_action(PermissionState::Granted),
|
||||
DesktopNotificationPermissionAction::Show
|
||||
);
|
||||
assert_eq!(
|
||||
desktop_notification_permission_action(PermissionState::Denied),
|
||||
DesktopNotificationPermissionAction::Reject
|
||||
);
|
||||
assert_eq!(
|
||||
desktop_notification_permission_action(PermissionState::Prompt),
|
||||
DesktopNotificationPermissionAction::Request
|
||||
);
|
||||
assert_eq!(
|
||||
desktop_notification_permission_action(PermissionState::PromptWithRationale),
|
||||
DesktopNotificationPermissionAction::Request
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_title_normalization_requires_visible_text() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -2,6 +2,7 @@ pub(crate) mod capabilities;
|
||||
mod clipboard;
|
||||
mod dispatch;
|
||||
pub(crate) mod files;
|
||||
mod notifications;
|
||||
pub(crate) mod protocol;
|
||||
mod share;
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse};
|
||||
use serde_json::Value;
|
||||
use tauri_plugin_notification::{NotificationExt, PermissionState};
|
||||
|
||||
const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80;
|
||||
const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240;
|
||||
|
||||
fn normalize_plain_text(
|
||||
value: Option<&str>,
|
||||
max_length: usize,
|
||||
required: bool,
|
||||
) -> Option<Option<String>> {
|
||||
let Some(value) = value else {
|
||||
return if required { None } else { Some(None) };
|
||||
};
|
||||
if value.chars().any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if text.is_empty() {
|
||||
return if required { None } else { Some(None) };
|
||||
}
|
||||
|
||||
Some(Some(text.chars().take(max_length).collect()))
|
||||
}
|
||||
|
||||
fn local_notification_payload(
|
||||
request: &HostBridgeRequest,
|
||||
) -> Result<(String, Option<String>), HostBridgeResponse> {
|
||||
let payload = request
|
||||
.payload
|
||||
.as_ref()
|
||||
.ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?;
|
||||
let title = match normalize_plain_text(
|
||||
payload.get("title").and_then(Value::as_str),
|
||||
LOCAL_NOTIFICATION_TITLE_MAX_LENGTH,
|
||||
true,
|
||||
) {
|
||||
Some(Some(title)) => title,
|
||||
_ => {
|
||||
return Err(failed(
|
||||
request.id.clone(),
|
||||
"invalid_request",
|
||||
"title is required",
|
||||
))
|
||||
}
|
||||
};
|
||||
let body = match normalize_plain_text(
|
||||
payload.get("body").and_then(Value::as_str),
|
||||
LOCAL_NOTIFICATION_BODY_MAX_LENGTH,
|
||||
false,
|
||||
) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
return Err(failed(
|
||||
request.id.clone(),
|
||||
"invalid_request",
|
||||
"body is invalid",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok((title, body))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum DesktopNotificationPermissionAction {
|
||||
Show,
|
||||
Request,
|
||||
Reject,
|
||||
}
|
||||
|
||||
fn desktop_notification_permission_action(
|
||||
permission_state: PermissionState,
|
||||
) -> DesktopNotificationPermissionAction {
|
||||
match permission_state {
|
||||
PermissionState::Granted => DesktopNotificationPermissionAction::Show,
|
||||
PermissionState::Denied => DesktopNotificationPermissionAction::Reject,
|
||||
PermissionState::Prompt | PermissionState::PromptWithRationale => {
|
||||
DesktopNotificationPermissionAction::Request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_desktop_local_notification(
|
||||
app: &tauri::AppHandle,
|
||||
request: &HostBridgeRequest,
|
||||
) -> Result<(), HostBridgeResponse> {
|
||||
let (title, body) = local_notification_payload(request)?;
|
||||
let notification_manager = app.notification();
|
||||
let permission_state = notification_manager
|
||||
.permission_state()
|
||||
.map_err(|error| failed(request.id.clone(), "host_error", error.to_string()))?;
|
||||
let mut permission_action = desktop_notification_permission_action(permission_state);
|
||||
if permission_action == DesktopNotificationPermissionAction::Request {
|
||||
let requested_state = notification_manager
|
||||
.request_permission()
|
||||
.map_err(|error| failed(request.id.clone(), "host_error", error.to_string()))?;
|
||||
permission_action = desktop_notification_permission_action(requested_state);
|
||||
}
|
||||
if permission_action != DesktopNotificationPermissionAction::Show {
|
||||
return Err(failed(
|
||||
request.id.clone(),
|
||||
"host_error",
|
||||
"notification permission denied",
|
||||
));
|
||||
}
|
||||
|
||||
let mut notification = app.notification().builder().title(title);
|
||||
if let Some(body) = body {
|
||||
notification = notification.body(body);
|
||||
}
|
||||
|
||||
notification
|
||||
.show()
|
||||
.map_err(|error| failed(request.id.clone(), "host_error", error.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host_bridge::protocol::request;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn local_notification_payload_is_normalized() {
|
||||
let mut request = request("notification.showLocal");
|
||||
request.payload = Some(json!({
|
||||
"title": " 生成完成 ",
|
||||
"body": " 作品已准备好 可以试玩 "
|
||||
}));
|
||||
|
||||
let (title, body) = local_notification_payload(&request).expect("payload");
|
||||
|
||||
assert_eq!(title, "生成完成");
|
||||
assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_notification_payload_rejects_empty_and_control_text() {
|
||||
let mut empty = request("notification.showLocal");
|
||||
empty.payload = Some(json!({
|
||||
"title": " "
|
||||
}));
|
||||
|
||||
let response = local_notification_payload(&empty).expect_err("empty title");
|
||||
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
|
||||
let mut control = request("notification.showLocal");
|
||||
control.payload = Some(json!({
|
||||
"title": "生成\n完成"
|
||||
}));
|
||||
|
||||
let response = local_notification_payload(&control).expect_err("control title");
|
||||
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_notification_permission_state_controls_delivery() {
|
||||
assert_eq!(
|
||||
desktop_notification_permission_action(PermissionState::Granted),
|
||||
DesktopNotificationPermissionAction::Show
|
||||
);
|
||||
assert_eq!(
|
||||
desktop_notification_permission_action(PermissionState::Denied),
|
||||
DesktopNotificationPermissionAction::Reject
|
||||
);
|
||||
assert_eq!(
|
||||
desktop_notification_permission_action(PermissionState::Prompt),
|
||||
DesktopNotificationPermissionAction::Request
|
||||
);
|
||||
assert_eq!(
|
||||
desktop_notification_permission_action(PermissionState::PromptWithRationale),
|
||||
DesktopNotificationPermissionAction::Request
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@
|
||||
- 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-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 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 覆盖托盘配置、菜单动作映射和关闭策略。
|
||||
@@ -2660,8 +2661,8 @@
|
||||
## 2026-06-18 桌面壳通知权限门禁
|
||||
|
||||
- 背景:桌面壳已经声明并实现 `notification.showLocal`,且 Tauri capability 只授权 `allow-host-bridge-request`;但 Rust handler 在清洗 payload 后直接调用 `notification.show()`,没有显式检查系统通知权限,也没有把权限拒绝固定成 HostBridge 失败语义。
|
||||
- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs` 在发送即时本地通知前先调用 `permission_state()`,已授权才发送;处于 prompt / prompt-with-rationale 时只在 Rust 侧调用 `request_permission()` 后复判;最终未授权返回 `host_error: notification permission denied`。桌面壳仍不把 `notification:*` 插件命令加入 capability permissions,不向 H5 暴露 notification 插件 JS guest API、远程推送、定时提醒或通知 token。
|
||||
- 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。
|
||||
- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs` 在发送即时本地通知前先调用 `permission_state()`,已授权才发送;处于 prompt / prompt-with-rationale 时只在 Rust 侧调用 `request_permission()` 后复判;最终未授权返回 `host_error: notification permission denied`。`dispatch.rs` 只保留 method 委托和 HostBridge 响应映射。桌面壳仍不把 `notification:*` 插件命令加入 capability permissions,不向 H5 暴露 notification 插件 JS guest API、远程推送、定时提醒或通知 token。
|
||||
- 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。
|
||||
- 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-19 桌面壳主窗口启动门禁
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -297,6 +297,7 @@ const expectedDesktopHostBridgeRustFiles = [
|
||||
'dispatch.rs',
|
||||
'files.rs',
|
||||
'mod.rs',
|
||||
'notifications.rs',
|
||||
'protocol.rs',
|
||||
'share.rs',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user