64f496d091
将 app.setTitle 标题长度和控制字符规则提升到共享 HostBridge 契约 让 H5 facade 和 Tauri 壳按共享窗口标题边界归一化载荷 增加标题边界测试和三端壳总门禁反查 同步宿主壳协议文档和共享决策记录
790 lines
29 KiB
Rust
790 lines
29 KiB
Rust
use crate::host_bridge::capabilities::capabilities;
|
|
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,
|
|
};
|
|
use crate::host_bridge::protocol::{
|
|
failed, ok, required_string_payload, validate_request, HostBridgeRequest, HostBridgeResponse,
|
|
HostBridgeRuntime, HOST_BRIDGE_VERSION,
|
|
};
|
|
use crate::host_bridge::share::{share_text_from_request, DesktopShareState};
|
|
use crate::shell::webview::{
|
|
color_scheme_from_theme, desktop_platform, normalize_external_url, normalize_native_page_url,
|
|
resolve_desktop_network_status,
|
|
};
|
|
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};
|
|
use tauri_plugin_opener::OpenerExt;
|
|
|
|
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> {
|
|
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 badge_count_payload(request: &HostBridgeRequest) -> Result<Option<i64>, HostBridgeResponse> {
|
|
let count = request
|
|
.payload
|
|
.as_ref()
|
|
.and_then(|value| value.get("count"))
|
|
.and_then(Value::as_i64)
|
|
.ok_or_else(|| {
|
|
failed(
|
|
request.id.clone(),
|
|
"invalid_request",
|
|
"count must be an integer between 0 and 99999",
|
|
)
|
|
})?;
|
|
|
|
if !(0..=BADGE_COUNT_MAX).contains(&count) {
|
|
return Err(failed(
|
|
request.id.clone(),
|
|
"invalid_request",
|
|
"count must be an integer between 0 and 99999",
|
|
));
|
|
}
|
|
|
|
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,
|
|
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 resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse {
|
|
if let Some(response) = validate_request(&request) {
|
|
return response;
|
|
}
|
|
|
|
match request.method.as_str() {
|
|
"host.getRuntime" => ok(
|
|
request.id,
|
|
json!(HostBridgeRuntime {
|
|
shell: "tauri_desktop",
|
|
platform: desktop_platform(),
|
|
host_version: env!("CARGO_PKG_VERSION"),
|
|
bridge_version: HOST_BRIDGE_VERSION,
|
|
capabilities: capabilities(),
|
|
}),
|
|
),
|
|
_ => failed(
|
|
request.id,
|
|
"unsupported_method",
|
|
format!("{} unsupported in desktop shell", request.method),
|
|
),
|
|
}
|
|
}
|
|
|
|
pub(super) async fn execute_host_bridge_request(
|
|
app: tauri::AppHandle,
|
|
request: HostBridgeRequest,
|
|
) -> HostBridgeResponse {
|
|
if let Some(response) = validate_request(&request) {
|
|
return response;
|
|
}
|
|
|
|
match request.method.as_str() {
|
|
"app.openExternalUrl" => {
|
|
let url = match required_string_payload(&request, "url")
|
|
.ok()
|
|
.and_then(normalize_external_url)
|
|
{
|
|
Some(url) => url,
|
|
None => {
|
|
return failed(
|
|
request.id,
|
|
"invalid_request",
|
|
"url must use an allowed external protocol",
|
|
)
|
|
}
|
|
};
|
|
|
|
match app.opener().open_url(url, None::<&str>) {
|
|
Ok(()) => ok(request.id, json!(true)),
|
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
|
}
|
|
}
|
|
"appearance.getColorScheme" => match app.get_webview_window("main") {
|
|
Some(window) => match window.theme() {
|
|
Ok(theme) => ok(
|
|
request.id,
|
|
json!({
|
|
"colorScheme": color_scheme_from_theme(theme)
|
|
}),
|
|
),
|
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
|
},
|
|
None => failed(request.id, "host_error", "main window not found"),
|
|
},
|
|
"navigation.openNativePage" => {
|
|
let url = match required_string_payload(&request, "url")
|
|
.ok()
|
|
.and_then(normalize_native_page_url)
|
|
{
|
|
Some(url) => url,
|
|
None => {
|
|
return failed(
|
|
request.id,
|
|
"invalid_request",
|
|
"url must use an allowed same-origin H5 route",
|
|
)
|
|
}
|
|
};
|
|
|
|
match app.get_webview_window("main") {
|
|
Some(window) => match window.navigate(url) {
|
|
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.reloadWebView" => match app.get_webview_window("main") {
|
|
Some(window) => match window.reload() {
|
|
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"),
|
|
},
|
|
"clipboard.writeText" => {
|
|
let text = match required_string_payload(&request, "text") {
|
|
Ok(text) => normalize_clipboard_text(text),
|
|
Err(response) => return response,
|
|
};
|
|
|
|
match app.clipboard().write_text(text) {
|
|
Ok(()) => ok(request.id, json!(true)),
|
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
|
}
|
|
}
|
|
"clipboard.readText" => match app.clipboard().read_text() {
|
|
Ok(text) => ok(
|
|
request.id,
|
|
json!({
|
|
"text": normalize_clipboard_text(&text),
|
|
}),
|
|
),
|
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
|
},
|
|
"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,
|
|
}),
|
|
)
|
|
}
|
|
"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.setBadgeCount" => {
|
|
let count = match badge_count_payload(&request) {
|
|
Ok(count) => count,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
match app.get_webview_window("main") {
|
|
Some(window) => match window.set_badge_count(count) {
|
|
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"),
|
|
}
|
|
}
|
|
"network.status" => {
|
|
let network_status =
|
|
tauri::async_runtime::spawn_blocking(resolve_desktop_network_status).await;
|
|
match network_status {
|
|
Ok(status) => ok(request.id, status),
|
|
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()),
|
|
}
|
|
}
|
|
"share.setTarget" => {
|
|
let target = request
|
|
.payload
|
|
.as_ref()
|
|
.and_then(|payload| payload.get("target"));
|
|
let Some(target) = target else {
|
|
return failed(request.id, "invalid_request", "target is required");
|
|
};
|
|
let share_state = app.state::<DesktopShareState>();
|
|
|
|
let response = match share_state.target.lock() {
|
|
Ok(mut current_target) => {
|
|
*current_target = Some(target.clone());
|
|
ok(request.id, json!(true))
|
|
}
|
|
Err(_) => failed(request.id, "host_error", "share target lock poisoned"),
|
|
};
|
|
response
|
|
}
|
|
"share.open" => {
|
|
let share_state = app.state::<DesktopShareState>();
|
|
let share_text = match share_text_from_request(&request, &share_state) {
|
|
Ok(text) => text,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
match app.clipboard().write_text(share_text) {
|
|
Ok(()) => ok(
|
|
request.id,
|
|
json!({
|
|
"action": "copied_to_clipboard"
|
|
}),
|
|
),
|
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
|
}
|
|
}
|
|
_ => resolve_host_bridge_request(request),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::host_bridge::protocol::request;
|
|
|
|
#[test]
|
|
fn runtime_response_reports_tauri_shell() {
|
|
let response = resolve_host_bridge_request(request("host.getRuntime"));
|
|
|
|
assert!(response.ok);
|
|
let result = response.result.expect("runtime result");
|
|
assert_eq!(result["shell"], "tauri_desktop");
|
|
assert_eq!(result["bridgeVersion"], HOST_BRIDGE_VERSION);
|
|
assert_eq!(result["capabilities"], json!(capabilities()));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("appearance.getColorScheme")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("host.events")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("app.lifecycle")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("network.status")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("network.statusChanged")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("share.open")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("share.setTarget")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("navigation.openNativePage")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("app.reloadWebView")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("app.setTitle")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("app.setBadgeCount")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("clipboard.readText")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.exportText")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.importText")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.importDocument")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.exportImage")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.importImage")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.importAudio")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.exportAudio")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("file.imageDropped")));
|
|
assert!(result["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!("notification.showLocal")));
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_method_is_explicit() {
|
|
for method in ["auth.requestLogin", "payment.request", "scanner.scanQrCode"] {
|
|
let response = resolve_host_bridge_request(request(method));
|
|
|
|
assert!(!response.ok);
|
|
let error = response.error.expect("error");
|
|
assert_eq!(error.code, "unsupported_method");
|
|
assert!(error.message.contains(method));
|
|
}
|
|
}
|
|
|
|
#[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");
|
|
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!(
|
|
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
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn badge_count_payload_accepts_clear_and_positive_counts() {
|
|
let mut clear = request("app.setBadgeCount");
|
|
clear.payload = Some(json!({ "count": 0 }));
|
|
assert_eq!(badge_count_payload(&clear).expect("clear badge"), None);
|
|
|
|
let mut count = request("app.setBadgeCount");
|
|
count.payload = Some(json!({ "count": 12 }));
|
|
assert_eq!(badge_count_payload(&count).expect("badge count"), Some(12));
|
|
}
|
|
|
|
#[test]
|
|
fn badge_count_payload_rejects_invalid_counts() {
|
|
for count in [json!(-1), json!(1.5), json!(100000), json!("1")] {
|
|
let mut invalid = request("app.setBadgeCount");
|
|
invalid.payload = Some(json!({ "count": count }));
|
|
|
|
let response = badge_count_payload(&invalid).expect_err("invalid count");
|
|
let error = response.error.expect("error");
|
|
assert_eq!(error.code, "invalid_request");
|
|
assert_eq!(
|
|
error.message,
|
|
"count must be an integer between 0 and 99999"
|
|
);
|
|
}
|
|
}
|
|
}
|