From a7988f70f8c1b6ab262d436f3ed28335b20480c5 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 1 Oct 2026 14:13:53 +0700 Subject: [PATCH] macOS find: page matches drawn with the CSS Custom Highlight API (window.find showed nothing while the page was not the key view); --diag logs exit requests, window close/destroy and each native pane placement on macOS Co-Authored-By: Claude Opus 5.5 --- crates/waybetter-host/src/app.rs | 20 ++++- crates/waybetter-host/src/find.rs | 107 ++++++++++++++++++++------- crates/waybetter-host/src/host.rs | 20 +++-- packages/shell/src/frame/FindBar.tsx | 2 +- 4 files changed, 113 insertions(+), 36 deletions(-) diff --git a/crates/waybetter-host/src/app.rs b/crates/waybetter-host/src/app.rs index 088280e..be0539f 100644 --- a/crates/waybetter-host/src/app.rs +++ b/crates/waybetter-host/src/app.rs @@ -699,7 +699,7 @@ async fn tab_zoom(webview: Webview, app: AppHandle, id: String, factor: Option) -> Result<(), String> { shell_only(&webview)?; @@ -1454,8 +1454,22 @@ impl App { handler(invoke) } }) - .run(context) - .unwrap_or_else(|error| panic!("Failed to launch {}: {error}", config.title)); + .build(context) + .unwrap_or_else(|error| panic!("Failed to launch {}: {error}", config.title)) + .run(|_, event| log_run_event(&event)); + } +} + +/// Why the app is going away, for `--diag` ([`crate::diag`]): an exit request with its code (None is a +/// polite quit from outside: Cmd+Q, the Dock, or another app such as an auto-quitter closing the last +/// window), window close/destroy events and the final exit. Nothing is written without diagnostics. +fn log_run_event(event: &tauri::RunEvent) { + match event { + tauri::RunEvent::ExitRequested { code, .. } => crate::diag::log("host", &format!("exit requested code={code:?}")), + tauri::RunEvent::Exit => crate::diag::log("host", "exit"), + tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::CloseRequested { .. }, .. } => crate::diag::log("host", &format!("window {label} close requested")), + tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::Destroyed, .. } => crate::diag::log("host", &format!("window {label} destroyed")), + _ => {} } } diff --git a/crates/waybetter-host/src/find.rs b/crates/waybetter-host/src/find.rs index 9c648eb..fa37a90 100644 --- a/crates/waybetter-host/src/find.rs +++ b/crates/waybetter-host/src/find.rs @@ -6,7 +6,7 @@ //! (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 page's matches drawn with the CSS Custom Highlight API (see [`fallback_script`]), which 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` @@ -86,24 +86,80 @@ pub fn request(action: &str, query: Option<&str>) -> Result 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. +/// The script native code evaluates where the engine's find cannot be driven natively (macOS, +/// Linux, an old WebView2): the page's matches drawn with the CSS Custom Highlight API (every match +/// tinted, the active one stronger and scrolled into view; independent of window focus, which the +/// engine's selection-based `window.find` is not: it shows nothing while the page is not the key +/// view), or `window.find` where an engine lacks that API. `stop` removes the highlights. +/// The query appears only as a JSON string literal; nothing is sent anywhere or stored: the +/// matches live in the page's highlight registry until `stop`, the query nowhere. 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){{}}}})();") - } - } + let action = match request.action { + FindAction::Start => "start", + FindAction::Next => "next", + FindAction::Previous => "previous", + FindAction::Stop => "stop", + }; + let query = request.query.as_ref().map_or_else(|| "null".to_owned(), |query| serde_json::to_string(query).unwrap_or_else(|_| "null".into())); + HIGHLIGHT_SCRIPT.replace("__ACTION__", action).replace("__QUERY__", &query) } +const HIGHLIGHT_SCRIPT: &str = r#"(()=>{try{ +const action="__ACTION__",query=__QUERY__; +const H=window.CSS&&CSS.highlights,ALL="wb-find",ACT="wb-find-active"; +const sheets=()=>document.adoptedStyleSheets||[]; +const stop=()=>{if(H){H.delete(ALL);H.delete(ACT);}try{document.adoptedStyleSheets=sheets().filter(s=>!s.__wbFind);}catch(e){}}; +if(action==="stop"||(action==="start"&&query===null)){stop();return;} +if(!H||typeof Highlight!=="function"){ + if(query!==null&&typeof window.find==="function"){if(action==="start"){const s=getSelection();if(s)s.removeAllRanges();}window.find(query,false,action==="previous",true,false,false,false);} + return; +} +const live=r=>r.startContainer.isConnected&&r.endContainer.isConnected; +const collect=q=>{ + const needle=q.toLowerCase(),out=[]; + if(!needle||needle.length!==q.length)return out; + const walker=document.createTreeWalker(document.body||document.documentElement,NodeFilter.SHOW_TEXT,{acceptNode:n=>{const p=n.parentElement;return !p||/^(SCRIPT|STYLE|NOSCRIPT|TEXTAREA)$/.test(p.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}}); + for(let n;(n=walker.nextNode())&&out.length<2000;){ + const t=n.nodeValue; + if(t.length=0&&out.length<2000;i=hay.indexOf(needle,i+needle.length)){ + const r=document.createRange();r.setStart(n,i);r.setEnd(n,i+needle.length);out.push(r); + } + } + return out; +}; +const active=H.get(ACT); +const current=active?[...active][0]:null; +let all,idx; +if(action==="start"){ + all=collect(query); + idx=all.findIndex(r=>r.getBoundingClientRect().bottom>0); + if(idx<0)idx=0; +}else{ + all=[...(H.get(ALL)||[])].filter(live); + if(!all.length&&query!==null)all=collect(query); + const at=current?all.indexOf(current):-1; + idx=!all.length?-1:action==="previous"?(at<=0?all.length-1:at-1):(at+1)%all.length; +} +H.set(ALL,new Highlight(...all)); +if(idx<0||!all.length){H.delete(ACT);} +else{ + const mark=new Highlight(all[idx]);mark.priority=1;H.set(ACT,mark); + const box=all[idx].getBoundingClientRect(); + if(box.top<0||box.bottom>innerHeight){const el=all[idx].startContainer.parentElement;if(el)el.scrollIntoView({block:"center",inline:"nearest"});} +} +if(!sheets().some(s=>s.__wbFind)){ + const sheet=new CSSStyleSheet(); + sheet.replaceSync("::highlight(wb-find){background-color:rgba(255,213,74,.55);color:inherit}::highlight(wb-find-active){background-color:#ff9632;color:#000}"); + sheet.__wbFind=true; + document.adoptedStyleSheets=[...sheets(),sheet]; +} +}catch(e){}})();"#; + #[cfg(test)] mod tests { use super::*; @@ -127,19 +183,16 @@ mod tests { #[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"); + assert!(script.contains(r#"const action="start",query="it's \"quoted\" \\ ${x} `y`";"#), "{script}"); 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. + assert!(next.contains(r#"const action="next",query="a";"#)); + assert!(fallback_script(&request("previous", Some("a")).unwrap()).contains(r#"action="previous""#)); + // Stop and a step without a query carry no query: stop clears the highlights, a step uses the page's matches. + assert!(fallback_script(&request("stop", None).unwrap()).contains(r#"action="stop",query=null;"#)); + assert!(fallback_script(&request("next", None).unwrap()).contains("query=null;")); + // Nothing leaves the page or stays in it: no network, storage, invoke or markup writes. for script in [script, next] { - for forbidden in ["fetch", "XMLHttpRequest", "localStorage", "sessionStorage", "invoke", "postMessage", "innerHTML", "document.cookie", "eval("] { + for forbidden in ["fetch", "XMLHttpRequest", "localStorage", "sessionStorage", "invoke", "postMessage", "innerHTML", "document.cookie", "eval(", "__ACTION__", "__QUERY__"] { assert!(!script.contains(forbidden), "{forbidden} in {script}"); } } diff --git a/crates/waybetter-host/src/host.rs b/crates/waybetter-host/src/host.rs index 489556a..b52768c 100644 --- a/crates/waybetter-host/src/host.rs +++ b/crates/waybetter-host/src/host.rs @@ -166,7 +166,7 @@ pub fn forget_tab_zoom(app: &AppHandle, id: &str) { /// `tab_find`: searches tab `id`'s page ([`crate::find`]). The engine's own find where this build /// can drive it (WebView2's `ICoreWebView2Find`, which reports counts through `tab-find` events); -/// otherwise the evaluated `window.find` fallback. The query is kept nowhere. +/// otherwise the evaluated Custom Highlight fallback ([`crate::find::fallback_script`]). The query is kept nowhere. pub fn tab_find(app: &AppHandle, id: &str, action: &str, query: Option<&str>) -> Result<(), String> { if !policy::valid_tab_id(id) { return Err("VALIDATION: invalid tab id".into()); @@ -451,10 +451,20 @@ pub fn place_child(webview: &Webview, x: f64, y: f64, width: f64, // One native move+resize: separate set_position/set_size calls were two WebView2 `SetBounds` // (the page re-laid out at the old size, then at the new one) and two main-thread messages. #[cfg(not(target_os = "linux"))] - let _ = webview.set_bounds(tauri::Rect { - position: tauri::LogicalPosition::new(x, y).into(), - size: tauri::LogicalSize::new(width, height).into(), - }); + { + let _ = webview.set_bounds(tauri::Rect { + position: tauri::LogicalPosition::new(x, y).into(), + size: tauri::LogicalSize::new(width, height).into(), + }); + #[cfg(target_os = "macos")] + if crate::diag::active() { + let actual = webview.bounds().map(|b| { + let (p, s) = (b.position.to_logical::(1.0), b.size.to_logical::(1.0)); + format!("{},{} {}x{}", p.x, p.y, s.width, s.height) + }); + crate::diag::log("host", &format!("place {} asked {x},{y} {width}x{height} now {actual:?}", webview.label())); + } + } } /// Linux: the shell webview becomes the base layer of a GtkOverlay filling the window, which diff --git a/packages/shell/src/frame/FindBar.tsx b/packages/shell/src/frame/FindBar.tsx index a22fe51..bf5d908 100644 --- a/packages/shell/src/frame/FindBar.tsx +++ b/packages/shell/src/frame/FindBar.tsx @@ -2,7 +2,7 @@ // pane's toolbar while it is open (so the page under it does not move and, in split view, the bar // is plainly the searched pane's). Native code does the searching (`tab_find`, shell-only): the // engine's own find where the build can drive it (WebView2: highlights and a match count, which -// arrive as `tab-find` events), else the engine's `window.find` evaluated by native code (next and +// arrive as `tab-find` events), else a Custom Highlight script evaluated by native code (next and // previous work, no count). Only what the page has rendered can be found, as in a browser: a // virtualized list (X's timeline) holds the posts near the viewport, not the whole feed. //