musealpha / src-tauri /src /browser /commands.rs
asdf98's picture
fix: remove unused RECENT_CAPTURE_KEYS static, add global toast event for toolbar exports
01edafb verified
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();}})();"#;