File size: 14,235 Bytes
01edafb 9150827 941978a 760eaf3 941978a eceaf26 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 f737a54 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 941978a 9150827 0f54479 9150827 3c96055 0f54479 9150827 01edafb 9150827 2443464 9150827 760eaf3 9150827 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
use tauri::webview::{PageLoadEvent, WebviewBuilder};
use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Url, WebviewUrl};
use super::layout::{hide_tab, resize_active, show_tab};
use super::navigation::{navigation_blocked, resolve_url};
use super::tab_manager::*;
use crate::adblock::engine::AdBlockState;
use crate::adblock::scripts;
use crate::state::AppState;
static SEEN_CAPTURE_IDS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
const CONTEXT_MENU_BLOCK_JS: &str = r#"
(function(){
if(window.__muse_ctx_installed) return;
window.__muse_ctx_installed = true;
document.addEventListener('contextmenu', function(e) {
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
try { var t=e.target; window.__TAURI_INTERNALS__.invoke('browser_context_menu',{x:e.screenX,y:e.screenY,clientX:e.clientX,clientY:e.clientY,tagName:t.tagName||null,src:t.src||t.currentSrc||null,href:t.closest&&t.closest('a')?t.closest('a').href:null,text:(window.getSelection()||'').toString().slice(0,200)||null,pageUrl:location.href}); } catch(ex) {}
return false;
}, true);
window.addEventListener('contextmenu', function(e){ e.preventDefault(); return false; }, true);
})();
"#;
#[tauri::command]
pub async fn browser_init(app: AppHandle, layout: ViewportLayout) -> Result<BrowserSnapshot, String> { let existing=snapshot(&app)?; if !existing.tabs.is_empty(){ if layout.width>10.0&&layout.height>10.0{resize_active(&app,&layout)?;} return Ok(existing);} create_tab_inner(&app,"https://duckduckgo.com",&layout).await?; snapshot(&app) }
#[tauri::command]
pub async fn browser_set_visible(app: AppHandle, visible: bool, layout: ViewportLayout) -> Result<(), String> { let active={let state=app.state::<AppState>();let tabs=state.tabs.lock().map_err(|_|"lock")?;tabs.active.clone()}; if let Some(active_id)=active{ if visible&&layout.width>10.0&&layout.height>10.0{show_tab(&app,&active_id,&layout)?;}else{hide_tab(&app,&active_id)?;} } Ok(()) }
#[tauri::command]
pub fn browser_hide_all(app: AppHandle) -> Result<(), String> { let ids={let state=app.state::<AppState>();let tabs=state.tabs.lock().map_err(|_|"lock")?;tabs.order.clone()}; for id in ids{let _=hide_tab(&app,&id);} Ok(()) }
#[tauri::command]
pub async fn tab_create(app: AppHandle, url: String, layout: ViewportLayout) -> Result<BrowserSnapshot, String> { let resolved=resolve_url(&url); if navigation_blocked(&app,&resolved){return snapshot(&app);} create_tab_inner(&app,&resolved,&layout).await?; snapshot(&app) }
#[tauri::command]
pub async fn tab_activate(app: AppHandle, tab_id: String, layout: ViewportLayout) -> Result<BrowserSnapshot, String> { let previous={let state=app.state::<AppState>();let mut tabs=state.tabs.lock().map_err(|_|"lock")?;let previous=tabs.active.clone();if !tabs.tabs.contains_key(&tab_id){return Err(format!("tab not found: {tab_id}"));}tabs.active=Some(tab_id.clone());if let Some(tab)=tabs.tabs.get_mut(&tab_id){tab.last_active=chrono::Utc::now().timestamp();}previous}; if let Some(prev)=previous{if prev!=tab_id{hide_tab(&app,&prev)?;}} if layout.width>10.0&&layout.height>10.0{show_tab(&app,&tab_id,&layout)?;} emit_snapshot(&app)?; snapshot(&app) }
#[tauri::command]
pub async fn tab_close(app: AppHandle, tab_id: String, layout: ViewportLayout) -> Result<BrowserSnapshot, String> { let label={let state=app.state::<AppState>();let mut tabs=state.tabs.lock().map_err(|_|"lock")?;let label=tabs.tabs.get(&tab_id).map(|t|t.label.clone());if let Some(tab)=tabs.tabs.remove(&tab_id){tabs.push_closed(tab);}tabs.order.retain(|id|id!=&tab_id);if tabs.active.as_deref()==Some(&tab_id){tabs.active=tabs.order.last().cloned();}label}; if let Some(label)=label{if let Some(webview)=app.get_webview(&label){let _=webview.close();}} let active={let state=app.state::<AppState>();let tabs=state.tabs.lock().map_err(|_|"lock")?;tabs.active.clone()}; if let Some(active_id)=active{if layout.width>10.0&&layout.height>10.0{show_tab(&app,&active_id,&layout)?;}} emit_snapshot(&app)?; snapshot(&app) }
#[tauri::command]
pub async fn tab_restore(app: AppHandle, layout: ViewportLayout) -> Result<BrowserSnapshot, String> { let url={let state=app.state::<AppState>();let mut tabs=state.tabs.lock().map_err(|_|"lock")?;tabs.pop_closed().map(|t|t.url)}; if let Some(url)=url{create_tab_inner(&app,&url,&layout).await?;} snapshot(&app) }
#[tauri::command]
pub async fn tab_navigate(app: AppHandle, tab_id: String, url: String) -> Result<BrowserSnapshot, String> { let resolved=resolve_url(&url); if navigation_blocked(&app,&resolved){return snapshot(&app);} let label={let state=app.state::<AppState>();let mut tabs=state.tabs.lock().map_err(|_|"lock")?;let tab=tabs.tabs.get_mut(&tab_id).ok_or("tab not found")?;tab.url=resolved.clone();tab.loading=true;tab.can_go_back=true;tab.label.clone()}; let webview=app.get_webview(&label).ok_or("webview not found")?; webview.navigate(Url::parse(&resolved).map_err(|e|e.to_string())?).map_err(|e|e.to_string())?; emit_snapshot(&app)?; snapshot(&app) }
#[tauri::command]
pub async fn tab_reload(app: AppHandle, tab_id: String) -> Result<(), String> { let label=tab_label(&app,&tab_id)?; app.get_webview(&label).ok_or("webview not found")?.reload().map_err(|e|e.to_string()) }
#[tauri::command]
pub async fn tab_back(app: AppHandle, tab_id: String) -> Result<(), String> { eval_on_tab(&app,&tab_id,"history.back()")?; update_tab_field(&app,&tab_id,|t|{t.can_go_forward=true;}); emit_snapshot(&app)?; Ok(()) }
#[tauri::command]
pub async fn tab_forward(app: AppHandle, tab_id: String) -> Result<(), String> { eval_on_tab(&app,&tab_id,"history.forward()")?; update_tab_field(&app,&tab_id,|t|{t.can_go_back=true;}); emit_snapshot(&app)?; Ok(()) }
#[tauri::command]
pub async fn tab_zoom(app: AppHandle, tab_id: String, zoom: f64) -> Result<BrowserSnapshot, String> { let zoom=zoom.clamp(0.5,2.0); let label={let state=app.state::<AppState>();let mut tabs=state.tabs.lock().map_err(|_|"lock")?;let (label,domain)={let tab=tabs.tabs.get_mut(&tab_id).ok_or("tab not found")?;tab.zoom=zoom;let domain=extract_domain(&tab.url);(tab.label.clone(),domain)};tabs.remember_zoom(&domain,zoom);label}; app.get_webview(&label).ok_or("webview not found")?.set_zoom(zoom).map_err(|e|e.to_string())?; snapshot(&app) }
#[tauri::command]
pub async fn tab_resize(app: AppHandle, layout: ViewportLayout) -> Result<(), String> { resize_active(&app,&layout) }
#[tauri::command]
pub fn tab_get_all(app: AppHandle) -> Result<BrowserSnapshot, String> { snapshot(&app) }
#[tauri::command]
pub fn tab_pin(app: AppHandle, tab_id: String, pinned: bool) -> Result<BrowserSnapshot, String> { update_tab_field(&app,&tab_id,|t|{t.pinned=pinned;}); snapshot(&app) }
#[tauri::command]
pub async fn tab_find(app: AppHandle, tab_id: String, query: String) -> Result<u32, String> { let q=serde_json::to_string(&query).unwrap_or("\"\"".into()); eval_on_tab(&app,&tab_id,&format!(r#"(function(){{window.__muse_find_cleanup&&window.__muse_find_cleanup();if(!{q})return 0;const text={q};const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let count=0;const marks=[];while(walker.nextNode()){{const node=walker.currentNode;const idx=node.textContent.toLowerCase().indexOf(text.toLowerCase());if(idx>=0){{const range=document.createRange();range.setStart(node,idx);range.setEnd(node,idx+text.length);const mark=document.createElement('mark');mark.style.cssText='background:#C49A3C;color:#100E0B;border-radius:2px;padding:0 1px';range.surroundContents(mark);marks.push(mark);count++;}}}}if(marks.length>0)marks[0].scrollIntoView({{block:'center'}});window.__muse_find_cleanup=()=>marks.forEach(m=>{{const p=m.parentNode;if(p){{p.replaceChild(document.createTextNode(m.textContent||''),m);p.normalize();}}}});return count;}})()"#))?; Ok(0) }
#[tauri::command]
pub async fn tab_find_clear(app: AppHandle, tab_id: String) -> Result<(), String> { eval_on_tab(&app,&tab_id,"window.__muse_find_cleanup && window.__muse_find_cleanup()") }
pub(crate) async fn create_tab_inner(app:&AppHandle,url:&str,_layout:&ViewportLayout)->Result<String,String>{
let id_num=app.state::<AppState>().next_tab_id.fetch_add(1,std::sync::atomic::Ordering::Relaxed);
let id=format!("tab-{id_num}"); let label=format!("muse-tab-{id_num}"); let parsed=Url::parse(url).map_err(|e|e.to_string())?;
let init_script=scripts::build_init_script(&scripts::blocked_domains_json()); let full_init=format!("{init_script}\n{CONTEXT_MENU_BLOCK_JS}\n{FAVICON_SCRIPT}");
let app_for_load=app.clone(); let id_for_load=id.clone(); let app_for_title=app.clone(); let id_for_title=id.clone(); let app_for_cosmetic=app.clone();
let builder=WebviewBuilder::new(label.clone(),WebviewUrl::External(parsed)).initialization_script(&full_init).on_page_load(move|webview,payload|{let url=payload.url().to_string();let loading=matches!(payload.event(),PageLoadEvent::Started);update_tab_field(&app_for_load,&id_for_load,|t|{if !loading&&t.url!=url&&!t.url.is_empty(){t.can_go_back=true;}t.url=url.clone();t.loading=loading;});if matches!(payload.event(),PageLoadEvent::Finished){let _=webview.eval(CONTEXT_MENU_BLOCK_JS);let _=webview.eval(scripts::hover_overlay_script());let adblock_state=app_for_cosmetic.state::<AdBlockState>();let css=adblock_state.get_cosmetic_css(&url);if !css.is_empty(){let escaped=css.replace('\\',"\\\\").replace('`',"\\`");let _=webview.eval(&format!("(function(){{const s=document.createElement('style');s.id='__muse_shield';s.textContent=`{escaped}`;document.head.appendChild(s)}})();"));}let scriptlet_js=adblock_state.get_injected_script(&url);if !scriptlet_js.is_empty(){let _=webview.eval(&format!("try{{{scriptlet_js}}}catch(e){{}}"));}let _=webview.eval("window.__muse_report_favicon && window.__muse_report_favicon()");let title={let state=app_for_load.state::<AppState>();state.tabs.lock().ok().and_then(|tabs|tabs.tabs.get(&id_for_load).map(|t|t.title.clone())).unwrap_or_default()};let _=crate::history::record_visit(&app_for_load,id_for_load.clone(),url.clone(),title);}}).on_document_title_changed(move|_wv,title|{update_tab_field(&app_for_title,&id_for_title,|t|{if !title.trim().is_empty(){t.title=title.clone();}});}).on_navigation({let app_nav=app.clone();move|url|{let s=url.as_str();if s.starts_with("muse-action://"){handle_muse_action(&app_nav,s);return false;}if navigation_blocked(&app_nav,s){return false;}true}});
let window=app.get_window("main").ok_or("main window not found")?; window.add_child(builder,LogicalPosition::new(-32000.0,-32000.0),LogicalSize::new(1.0,1.0)).map_err(|e|e.to_string())?;
let domain=extract_domain(url);let saved_zoom={let state=app.state::<AppState>();let tabs=state.tabs.lock().map_err(|_|"lock")?;tabs.get_zoom_for_domain(&domain)};let now=chrono::Utc::now().timestamp();let zoom=saved_zoom.unwrap_or(1.0);
let previous={let state=app.state::<AppState>();let mut tabs=state.tabs.lock().map_err(|_|"lock")?;let previous=tabs.active.clone();tabs.active=Some(id.clone());tabs.order.push(id.clone());tabs.tabs.insert(id.clone(),BrowserTab{id:id.clone(),label:label.clone(),url:url.to_string(),title:"New Tab".to_string(),favicon:None,loading:true,pinned:false,sleeping:false,zoom,can_go_back:false,can_go_forward:false,last_active:now});previous};
if zoom!=1.0{if let Some(webview)=app.get_webview(&label){let _=webview.set_zoom(zoom);}} if let Some(prev)=previous{if prev!=id{hide_tab(app,&prev)?;}} emit_snapshot(app)?; Ok(id)
}
fn handle_muse_action(app:&AppHandle,raw:&str){
let rest=raw.trim_start_matches("muse-action://"); let (action,query)=rest.split_once('?').unwrap_or((rest,""));
let params:std::collections::HashMap<String,String>=query.split('&').filter_map(|pair|{let(k,v)=pair.split_once('=')?;Some((percent_decode(k),percent_decode(v)))}).collect();
let capture_id=params.get("captureId").cloned().unwrap_or_default();
if !capture_id.is_empty(){let seen=SEEN_CAPTURE_IDS.get_or_init(||Mutex::new(HashSet::new()));if let Ok(mut s)=seen.lock(){if s.contains(&capture_id){return;}s.insert(capture_id.clone());if s.len()>512{if let Some(first)=s.iter().next().cloned(){s.remove(&first);}}}}
let url=params.get("url").cloned().unwrap_or_default(); if url.is_empty(){return;}
let source=params.get("source").cloned().unwrap_or_default(); let title=params.get("title").cloned().unwrap_or_else(||"Web Reference".to_string()); let w=params.get("w").and_then(|s|s.parse::<u32>().ok()).unwrap_or(300); let h=params.get("h").and_then(|s|s.parse::<u32>().ok()).unwrap_or(200); let action=action.trim_end_matches('/').to_string(); let app2=app.clone();
tauri::async_runtime::spawn(async move{match crate::library::library_add_item(app2.clone(),url.clone(),Some(source.clone()),Some(title.clone())).await{Ok(item)=>{if action=="board"{let _=crate::board::board_add_image(app2.clone(),Some(item.id.clone()),item.data_url.clone(),120.0,120.0,300.0,200.0);}let _=app2.emit("board://image_added",serde_json::json!({"libraryId":item.id,"dataUrl":item.data_url,"url":item.url,"sourceUrl":item.source_url,"title":item.title,"width":item.width,"height":item.height,"colors":item.colors,"captureId":capture_id}));}Err(_)=>{let _=app2.emit("board://image_added",serde_json::json!({"url":url,"dataUrl":url,"sourceUrl":source,"title":title,"width":w,"height":h,"colors":[],"captureId":capture_id}));}}});
}
fn percent_decode(s:&str)->String{let bytes=s.as_bytes();let mut out=Vec::with_capacity(bytes.len());let mut i=0;while i<bytes.len(){if bytes[i]==b'%'&&i+2<bytes.len(){if let Ok(hex)=std::str::from_utf8(&bytes[i+1..i+3]){if let Ok(v)=u8::from_str_radix(hex,16){out.push(v);i+=3;continue;}}}out.push(if bytes[i]==b'+'{b' '}else{bytes[i]});i+=1;}String::from_utf8_lossy(&out).to_string()}
const FAVICON_SCRIPT:&str=r#"(function(){window.__muse_report_favicon=function(){const link=document.querySelector('link[rel~="icon"],link[rel="shortcut icon"],link[rel="apple-touch-icon"]');if(link&&link.href){try{window.__TAURI_INTERNALS__.invoke('__tab_favicon',{favicon:link.href});}catch{}}};if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',window.__muse_report_favicon);}else{window.__muse_report_favicon();}})();"#;
|