//! Find in page. The shell owns the find bar; a tab's page is searched natively through the //! shell-only `tab_find {id, action, query}`: `start` (a new query), `next`, `previous`, `stop` //! (the bar closed: highlights go). //! //! The engine's own find is used where this build can drive it: WebView2's `ICoreWebView2Find` //! (Windows, WebView2 Runtime with `ICoreWebView2_28`; its own find bar suppressed), which also //! reports the match count and the active match (`tab-find` events to the shell). Everywhere else //! (macOS, Linux, an older WebView2 Runtime) native code evaluates [`fallback_script`] in the tab: //! the engine's `window.find`, which selects the next match and scrolls to it but counts nothing. //! //! The query is data: it goes from the shell to the engine and is kept nowhere (no file, no log, //! no state in this crate). Tabs gain no command; a tab may only forward the key name `find` //! through `tab_shortcut`. Only what the page has rendered can be found, as in a browser. use serde::Serialize; /// The shell event with the engine's counts for a tab's find session. pub const EVENT_TAB_FIND: &str = "tab-find"; /// A query is one line of text a person typed. pub const MAX_QUERY: usize = 256; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FindAction { Start, Next, Previous, Stop, } impl FindAction { pub fn parse(action: &str) -> Result { match action { "start" => Ok(Self::Start), "next" => Ok(Self::Next), "previous" => Ok(Self::Previous), "stop" => Ok(Self::Stop), _ => Err("VALIDATION: unknown find action".into()), } } } /// What the engine reported for a tab's find session: how many matches and which one is active /// (0-based; -1 for none). #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct FindState { pub id: String, pub count: i32, pub index: i32, } impl FindState { /// From WebView2's counts: its `ActiveMatchIndex` starts at 1 (and is -1 without an active match); /// the shell's is 0-based. A negative count is none. pub fn from_webview2(id: &str, count: i32, active_one_based: i32) -> Self { let count = count.max(0); let index = if (1..=count).contains(&active_one_based) { active_one_based - 1 } else { -1 }; Self { id: id.to_owned(), count, index } } } /// A request as native code runs it: `start` carries the query, the others none. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FindRequest { pub action: FindAction, pub query: Option, } /// Validates a `tab_find` call: a known action; for `start` a query of 1..=256 characters with no /// control characters (one typed line). `next`/`previous` repeat the last query the shell sent for /// the fallback (the engine's own find keeps its session), so they may carry it too. pub fn request(action: &str, query: Option<&str>) -> Result { let action = FindAction::parse(action)?; let query = match query { Some(text) => { let count = text.chars().count(); if count == 0 || count > MAX_QUERY || text.chars().any(char::is_control) { return Err("VALIDATION: invalid find query".into()); } Some(text.to_owned()) } None => None, }; if action == FindAction::Start && query.is_none() { return Err("VALIDATION: find needs a query".into()); } Ok(FindRequest { action, query: if action == FindAction::Stop { None } else { query } }) } /// The script native code evaluates where the engine's find cannot be driven natively: the /// engine's `window.find` (case-insensitive, wrapping), or clearing the selection for `stop`. /// The query appears only as a JSON string literal; nothing is sent anywhere or stored in the page. pub fn fallback_script(request: &FindRequest) -> String { match (&request.action, &request.query) { (FindAction::Stop, _) | (_, None) => { "(()=>{try{const s=getSelection();if(s)s.removeAllRanges();}catch(e){}})();".to_owned() } (action, Some(query)) => { let text = serde_json::to_string(query).unwrap_or_else(|_| "\"\"".into()); // A new query starts from the top of the page; next/previous continue from the selection. let restart = if *action == FindAction::Start { "const s=getSelection();if(s)s.removeAllRanges();" } else { "" }; let backwards = *action == FindAction::Previous; format!("(()=>{{try{{{restart}if(typeof window.find==='function')window.find({text},false,{backwards},true,false,false,false);}}catch(e){{}}}})();") } } } #[cfg(test)] mod tests { use super::*; #[test] fn a_request_is_a_known_action_and_one_typed_line() { assert_eq!(request("start", Some("tauri")).unwrap(), FindRequest { action: FindAction::Start, query: Some("tauri".into()) }); assert_eq!(request("next", None).unwrap().action, FindAction::Next); assert_eq!(request("previous", Some("x")).unwrap().query.as_deref(), Some("x")); assert_eq!(request("stop", Some("left over")).unwrap(), FindRequest { action: FindAction::Stop, query: None }, "stop carries nothing"); assert!(request("start", None).is_err(), "start needs a query"); assert!(request("start", Some("")).is_err()); assert!(request("eval", Some("x")).is_err()); assert!(request("start", Some("two\nlines")).is_err()); assert!(request("start", Some("nul\0")).is_err()); assert!(request("start", Some(&"a".repeat(MAX_QUERY))).is_ok()); assert!(request("start", Some(&"a".repeat(MAX_QUERY + 1))).is_err()); assert!(request("start", Some(&"ş".repeat(MAX_QUERY))).is_ok(), "characters, not bytes"); } #[test] fn the_fallback_script_carries_the_query_only_as_a_string_literal() { let script = fallback_script(&request("start", Some("it's \"quoted\" \\ ${x} `y`")).unwrap()); assert!(script.contains(r#"window.find("it's \"quoted\" \\ ${x} `y`",false,false,true"#), "{script}"); assert!(script.contains("removeAllRanges"), "a new query starts from the top"); let next = fallback_script(&request("next", Some("a")).unwrap()); assert!(next.contains(r#"window.find("a",false,false,true"#) && !next.contains("removeAllRanges")); assert!(fallback_script(&request("previous", Some("a")).unwrap()).contains(r#"window.find("a",false,true,true"#)); // Stop (and a step without a query) only clears the selection. for request in [request("stop", None).unwrap(), request("next", None).unwrap()] { let script = fallback_script(&request); assert!(script.contains("removeAllRanges") && !script.contains("window.find"), "{script}"); } // Nothing leaves the page or stays in it: no network, storage, invoke or DOM writes. for script in [script, next] { for forbidden in ["fetch", "XMLHttpRequest", "localStorage", "sessionStorage", "invoke", "postMessage", "innerHTML", "document.cookie", "eval("] { assert!(!script.contains(forbidden), "{forbidden} in {script}"); } } } #[test] fn counts_are_numbers_about_the_tab() { let state = FindState { id: "t1".into(), count: 17, index: 2 }; assert_eq!(serde_json::to_string(&state).unwrap(), r#"{"id":"t1","count":17,"index":2}"#); // WebView2 counts its active match from 1 (seen live: "2/2" for the first of two). assert_eq!(FindState::from_webview2("t1", 2, 1), FindState { id: "t1".into(), count: 2, index: 0 }); assert_eq!(FindState::from_webview2("t1", 2, 2).index, 1); assert_eq!(FindState::from_webview2("t1", 2, -1).index, -1, "no active match"); assert_eq!(FindState::from_webview2("t1", 2, 3).index, -1, "out of range"); assert_eq!(FindState::from_webview2("t1", 0, 0), FindState { id: "t1".into(), count: 0, index: -1 }); assert_eq!(FindState::from_webview2("t1", -5, 1).count, 0); } }