diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index b13428111..bbba1db18 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -2465,9 +2465,12 @@ if ( for (const snippet of [ 'WINDOW_TITLE_MAX_LENGTH', 'normalize_window_title', + 'window_title_from_request', 'required_string_payload(request, "title")', 'window.set_title(&title)', '"title is required"', + 'window_title_request_rejects_missing_or_invalid_payload', + 'window_title_request_trims_and_truncates_shared_boundary', ]) { if (!desktopHostBridgeTitleSource.includes(snippet)) { throw new Error(`desktop shell title module is missing ${snippet}`); diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/title.rs b/apps/desktop-shell/src-tauri/src/host_bridge/title.rs index 9e4bfdaae..43a6b0f45 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/title.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/title.rs @@ -15,16 +15,22 @@ fn normalize_window_title(raw_title: &str) -> Option { Some(title.chars().take(WINDOW_TITLE_MAX_LENGTH).collect()) } +fn window_title_from_request( + request: &HostBridgeRequest, +) -> Result { + required_string_payload(request, "title") + .ok() + .and_then(normalize_window_title) + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required")) +} + 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"), + let title = match window_title_from_request(request) { + Ok(title) => title, + Err(response) => return response, }; match app.get_webview_window("main") { @@ -39,6 +45,8 @@ pub(crate) fn set_desktop_host_bridge_window_title( #[cfg(test)] mod tests { use super::*; + use crate::host_bridge::protocol::request; + use serde_json::json; #[test] fn window_title_normalization_requires_visible_text() { @@ -58,4 +66,36 @@ mod tests { 80 ); } + + #[test] + fn window_title_request_rejects_missing_or_invalid_payload() { + for payload in [ + None, + Some(json!({})), + Some(json!({ "title": 1 })), + Some(json!({ "title": " " })), + Some(json!({ "title": "Genarrative\nDev" })), + ] { + let mut request = request("app.setTitle"); + request.payload = payload; + + let response = window_title_from_request(&request).expect_err("invalid title"); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "title is required"); + } + } + + #[test] + fn window_title_request_trims_and_truncates_shared_boundary() { + let mut request = request("app.setTitle"); + request.payload = Some(json!({ + "title": format!(" {} ", "甲".repeat(120)) + })); + + let title = window_title_from_request(&request).expect("window title"); + + assert_eq!(title.chars().count(), WINDOW_TITLE_MAX_LENGTH); + assert!(title.chars().all(|character| character == '甲')); + } }