asdf98 commited on
Commit
9150827
·
verified ·
1 Parent(s): 7967f6e

fix: dedupe muse-action captures by captureId to prevent duplicate adds

Browse files
Files changed (1) hide show
  1. src-tauri/src/browser/commands.rs +41 -134
src-tauri/src/browser/commands.rs CHANGED
@@ -1,3 +1,5 @@
 
 
1
  use tauri::webview::{PageLoadEvent, WebviewBuilder};
2
  use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Url, WebviewUrl};
3
 
@@ -8,22 +10,15 @@ use crate::adblock::engine::AdBlockState;
8
  use crate::adblock::scripts;
9
  use crate::state::AppState;
10
 
 
 
11
  const CONTEXT_MENU_BLOCK_JS: &str = r#"
12
  (function(){
13
  if(window.__muse_ctx_installed) return;
14
  window.__muse_ctx_installed = true;
15
  document.addEventListener('contextmenu', function(e) {
16
  e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
17
- try {
18
- var t = e.target;
19
- window.__TAURI_INTERNALS__.invoke('browser_context_menu', {
20
- x: e.screenX, y: e.screenY, clientX: e.clientX, clientY: e.clientY,
21
- tagName: t.tagName || null, src: t.src || t.currentSrc || null,
22
- href: t.closest && t.closest('a') ? t.closest('a').href : null,
23
- text: (window.getSelection() || '').toString().slice(0, 200) || null,
24
- pageUrl: location.href
25
- });
26
- } catch(ex) {}
27
  return false;
28
  }, true);
29
  window.addEventListener('contextmenu', function(e){ e.preventDefault(); return false; }, true);
@@ -31,149 +26,61 @@ const CONTEXT_MENU_BLOCK_JS: &str = r#"
31
  "#;
32
 
33
  #[tauri::command]
34
- pub async fn browser_init(app: AppHandle, layout: ViewportLayout) -> Result<BrowserSnapshot, String> {
35
- let existing = snapshot(&app)?;
36
- if !existing.tabs.is_empty() { if layout.width > 10.0 && layout.height > 10.0 { resize_active(&app, &layout)?; } return Ok(existing); }
37
- create_tab_inner(&app, "https://duckduckgo.com", &layout).await?;
38
- snapshot(&app)
39
- }
40
-
41
  #[tauri::command]
42
- pub async fn browser_set_visible(app: AppHandle, visible: bool, layout: ViewportLayout) -> Result<(), String> {
43
- let active = { let state = app.state::<AppState>(); let tabs = state.tabs.lock().map_err(|_| "lock")?; tabs.active.clone() };
44
- 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)?; } }
45
- Ok(())
46
- }
47
-
48
  #[tauri::command]
49
- pub fn browser_hide_all(app: AppHandle) -> Result<(), String> {
50
- let ids = { let state = app.state::<AppState>(); let tabs = state.tabs.lock().map_err(|_| "lock")?; tabs.order.clone() };
51
- for id in ids { let _ = hide_tab(&app, &id); }
52
- Ok(())
53
- }
54
-
55
  #[tauri::command]
56
- 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) }
57
  #[tauri::command]
58
- 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) }
59
  #[tauri::command]
60
- 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) }
61
  #[tauri::command]
62
- 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) }
63
  #[tauri::command]
64
- 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) }
65
  #[tauri::command]
66
- 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()) }
67
  #[tauri::command]
68
- 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(()) }
69
  #[tauri::command]
70
- 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(()) }
71
  #[tauri::command]
72
- 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) }
73
  #[tauri::command]
74
- pub async fn tab_resize(app: AppHandle, layout: ViewportLayout) -> Result<(), String> { resize_active(&app, &layout) }
75
  #[tauri::command]
76
  pub fn tab_get_all(app: AppHandle) -> Result<BrowserSnapshot, String> { snapshot(&app) }
77
  #[tauri::command]
78
- 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) }
79
  #[tauri::command]
80
- 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) }
81
  #[tauri::command]
82
- 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()") }
83
-
84
- pub(crate) async fn create_tab_inner(app: &AppHandle, url: &str, _layout: &ViewportLayout) -> Result<String, String> {
85
- let id_num = app.state::<AppState>().next_tab_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
86
- let id = format!("tab-{id_num}"); let label = format!("muse-tab-{id_num}");
87
- let parsed = Url::parse(url).map_err(|e| e.to_string())?;
88
- let init_script = scripts::build_init_script(&scripts::blocked_domains_json());
89
- let full_init = format!("{init_script}\n{CONTEXT_MENU_BLOCK_JS}\n{FAVICON_SCRIPT}");
90
- let app_for_load = app.clone(); let id_for_load = id.clone();
91
- let app_for_title = app.clone(); let id_for_title = id.clone();
92
- let app_for_cosmetic = app.clone();
93
-
94
- let builder = WebviewBuilder::new(label.clone(), WebviewUrl::External(parsed))
95
- .initialization_script(&full_init)
96
- .on_page_load(move |webview, payload| {
97
- let url = payload.url().to_string();
98
- let loading = matches!(payload.event(), PageLoadEvent::Started);
99
- 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; });
100
- if matches!(payload.event(), PageLoadEvent::Finished) {
101
- let _ = webview.eval(CONTEXT_MENU_BLOCK_JS);
102
- let _ = webview.eval(scripts::hover_overlay_script());
103
- let adblock_state = app_for_cosmetic.state::<AdBlockState>();
104
- let css = adblock_state.get_cosmetic_css(&url);
105
- 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)}})();")); }
106
- let scriptlet_js = adblock_state.get_injected_script(&url);
107
- if !scriptlet_js.is_empty() { let _ = webview.eval(&format!("try{{{scriptlet_js}}}catch(e){{}}")); }
108
- let _ = webview.eval("window.__muse_report_favicon && window.__muse_report_favicon()");
109
- 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() };
110
- let _ = crate::history::record_visit(&app_for_load, id_for_load.clone(), url.clone(), title);
111
- }
112
- })
113
- .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(); } }); })
114
- .on_navigation({ let app_nav = app.clone(); move |url| {
115
- let s = url.as_str();
116
- // Process muse-action:// HERE because register_uri_scheme_protocol does NOT fire
117
- // for navigations that are blocked (returning false prevents the request from
118
- // reaching the network layer where the scheme protocol lives).
119
- if s.starts_with("muse-action://") {
120
- handle_muse_action(&app_nav, s);
121
- return false; // Block navigation — page stays on current URL
122
- }
123
- if navigation_blocked(&app_nav, s) { return false; }
124
- true
125
- }});
126
 
127
- let window = app.get_window("main").ok_or("main window not found")?;
128
- window.add_child(builder, LogicalPosition::new(-32000.0, -32000.0), LogicalSize::new(1.0, 1.0)).map_err(|e| e.to_string())?;
129
-
130
- let domain = extract_domain(url);
131
- let saved_zoom = { let state = app.state::<AppState>(); let tabs = state.tabs.lock().map_err(|_| "lock")?; tabs.get_zoom_for_domain(&domain) };
132
- let now = chrono::Utc::now().timestamp(); let zoom = saved_zoom.unwrap_or(1.0);
133
- 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 };
134
- if zoom != 1.0 { if let Some(webview) = app.get_webview(&label) { let _ = webview.set_zoom(zoom); } }
135
- if let Some(prev) = previous { if prev != id { hide_tab(app, &prev)?; } }
136
- emit_snapshot(app)?; Ok(id)
137
  }
138
 
139
- /// Process muse-action:// URLs from the hover overlay.
140
- /// Called from on_navigation (the only reliable interception point for child webview navigations).
141
- fn handle_muse_action(app: &AppHandle, raw: &str) {
142
- let rest = raw.trim_start_matches("muse-action://");
143
- let (action, query) = rest.split_once('?').unwrap_or((rest, ""));
144
- 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();
145
- let url = params.get("url").cloned().unwrap_or_default();
146
- if url.is_empty() { return; }
147
- let source = params.get("source").cloned().unwrap_or_default();
148
- let title = params.get("title").cloned().unwrap_or_else(|| "Web Reference".to_string());
149
- let w = params.get("w").and_then(|s| s.parse::<u32>().ok()).unwrap_or(300);
150
- let h = params.get("h").and_then(|s| s.parse::<u32>().ok()).unwrap_or(200);
151
- let action = action.trim_end_matches('/').to_string();
152
- let app2 = app.clone();
153
-
154
- tauri::async_runtime::spawn(async move {
155
- match crate::library::library_add_item(app2.clone(), url.clone(), Some(source.clone()), Some(title.clone())).await {
156
- Ok(item) => {
157
- if action == "board" {
158
- let _ = crate::board::board_add_image(app2.clone(), Some(item.id.clone()), item.data_url.clone(), 120.0, 120.0, 300.0, 200.0);
159
- }
160
- let _ = app2.emit("board://image_added", serde_json::json!({
161
- "libraryId": item.id, "dataUrl": item.data_url, "url": item.url,
162
- "sourceUrl": item.source_url, "title": item.title,
163
- "width": item.width, "height": item.height, "colors": item.colors
164
- }));
165
- }
166
- Err(_e) => {
167
- // Fallback: emit with raw URL so image still appears on canvas (as remote img)
168
- let _ = app2.emit("board://image_added", serde_json::json!({
169
- "url": url, "dataUrl": url, "sourceUrl": source, "title": title,
170
- "width": w, "height": h, "colors": []
171
- }));
172
- }
173
- }
174
- });
175
  }
176
 
177
- 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() }
178
-
179
- 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();}})();"#;
 
1
+ use std::collections::{HashMap, HashSet};
2
+ use std::sync::{Mutex, OnceLock};
3
  use tauri::webview::{PageLoadEvent, WebviewBuilder};
4
  use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Url, WebviewUrl};
5
 
 
10
  use crate::adblock::scripts;
11
  use crate::state::AppState;
12
 
13
+ static SEEN_CAPTURE_IDS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
14
+
15
  const CONTEXT_MENU_BLOCK_JS: &str = r#"
16
  (function(){
17
  if(window.__muse_ctx_installed) return;
18
  window.__muse_ctx_installed = true;
19
  document.addEventListener('contextmenu', function(e) {
20
  e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
21
+ 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) {}
 
 
 
 
 
 
 
 
 
22
  return false;
23
  }, true);
24
  window.addEventListener('contextmenu', function(e){ e.preventDefault(); return false; }, true);
 
26
  "#;
27
 
28
  #[tauri::command]
29
+ 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) }
 
 
 
 
 
 
30
  #[tauri::command]
31
+ 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(()) }
 
 
 
 
 
32
  #[tauri::command]
33
+ 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(()) }
 
 
 
 
 
34
  #[tauri::command]
35
+ 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) }
36
  #[tauri::command]
37
+ 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) }
38
  #[tauri::command]
39
+ 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) }
40
  #[tauri::command]
41
+ 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) }
42
  #[tauri::command]
43
+ 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) }
44
  #[tauri::command]
45
+ 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()) }
46
  #[tauri::command]
47
+ 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(()) }
48
  #[tauri::command]
49
+ 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(()) }
50
  #[tauri::command]
51
+ 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) }
52
  #[tauri::command]
53
+ pub async fn tab_resize(app: AppHandle, layout: ViewportLayout) -> Result<(), String> { resize_active(&app,&layout) }
54
  #[tauri::command]
55
  pub fn tab_get_all(app: AppHandle) -> Result<BrowserSnapshot, String> { snapshot(&app) }
56
  #[tauri::command]
57
+ 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) }
58
  #[tauri::command]
59
+ 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) }
60
  #[tauri::command]
61
+ 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()") }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
+ pub(crate) async fn create_tab_inner(app:&AppHandle,url:&str,_layout:&ViewportLayout)->Result<String,String>{
64
+ let id_num=app.state::<AppState>().next_tab_id.fetch_add(1,std::sync::atomic::Ordering::Relaxed);
65
+ let id=format!("tab-{id_num}"); let label=format!("muse-tab-{id_num}"); let parsed=Url::parse(url).map_err(|e|e.to_string())?;
66
+ 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}");
67
+ 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();
68
+ 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}});
69
+ 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())?;
70
+ 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);
71
+ 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};
72
+ 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)
73
  }
74
 
75
+ fn handle_muse_action(app:&AppHandle,raw:&str){
76
+ let rest=raw.trim_start_matches("muse-action://"); let (action,query)=rest.split_once('?').unwrap_or((rest,""));
77
+ let params:HashMap<String,String>=query.split('&').filter_map(|pair|{let(k,v)=pair.split_once('=')?;Some((percent_decode(k),percent_decode(v)))}).collect();
78
+ let capture_id=params.get("captureId").cloned().unwrap_or_default();
79
+ 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);if s.len()>512{if let Some(first)=s.iter().next().cloned(){s.remove(&first);}}}}
80
+ let url=params.get("url").cloned().unwrap_or_default(); if url.is_empty(){return;}
81
+ 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();
82
+ 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}));}}});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
 
85
+ 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()}
86
+ 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();}})();"#;