From 786f3670065f06e6daeb6d116301d245996a87ed Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Tue, 29 Sep 2026 09:46:24 -0400 Subject: [PATCH] v0.0.12 - Fix NIP-07 bridge on CSP-restricted sites (jumble.social login) and invisible custom scrollbars; make eval_js work under strict CSP --- Cargo.toml | 2 +- VERSION | 2 +- src/agent_tools.rs | 22 ++- src/bookmarks.rs | 462 ++++++++++++++++++++++++++++++++++++++++++++ src/db.rs | 16 +- src/key_handler.rs | 1 + src/main.rs | 5 + src/nostr_bridge.rs | 12 +- src/nostr_inject.rs | 197 +++++++++++++------ src/tab_manager.rs | 201 +++++++++++++++++++ src/version.rs | 4 +- www/bookmarks.html | 5 + www/bookmarks.js | 10 + 13 files changed, 870 insertions(+), 69 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 39d8737..e91220c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ [package] name = "sovereign_browser" -version = "0.0.11" +version = "0.0.12" edition = "2021" license = "MIT" description = "A Linux x86 web browser built on WebKitGTK with Nostr identity" diff --git a/VERSION b/VERSION index 2cfabea..8cbf02c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.11 +0.0.12 diff --git a/src/agent_tools.rs b/src/agent_tools.rs index 4245638..449f30c 100644 --- a/src/agent_tools.rs +++ b/src/agent_tools.rs @@ -278,6 +278,23 @@ fn with_active_webview(f: impl FnOnce(Option) + Send + 'sta /// blocks waiting for the main loop) — the agent loop and MCP server /// execute tools on worker threads. pub fn eval_js_sync(js: &str, timeout: std::time::Duration) -> Result { + // Pages with a strict CSP (no 'unsafe-eval') reject the indirect eval + // below with an EvalError. Isolated script worlds are exempt from the + // page's CSP, so retry there: the DOM is shared, only page-defined JS + // globals are not visible. + match eval_js_in_world(js, timeout, None) { + Err(e) if e.contains("__csp_eval_blocked") => { + eval_js_in_world(js, timeout, Some("sovereign-agent")) + } + other => other, + } +} + +fn eval_js_in_world( + js: &str, + timeout: std::time::Duration, + world: Option<&'static str>, +) -> Result { if glib::MainContext::default().is_owner() { return Err("eval_js_sync called on the main thread".to_string()); } @@ -290,7 +307,8 @@ pub fn eval_js_sync(js: &str, timeout: std::time::Duration) -> Result \ (typeof Node !== 'undefined' && v instanceof Node) ? (v.outerHTML || String(v)).slice(0, 2000) : v); }} \ - catch (e) {{ return JSON.stringify({{__error: String(e && e.stack || e)}}); }}", + catch (e) {{ if (e instanceof EvalError) return JSON.stringify({{__error: '__csp_eval_blocked'}}); \ + return JSON.stringify({{__error: String(e && e.stack || e)}}); }}", src = serde_json::to_string(js).unwrap_or_else(|_| "\"\"".into()) ); let (tx, rx) = std::sync::mpsc::channel::>(); @@ -299,7 +317,7 @@ pub fn eval_js_sync(js: &str, timeout: std::time::Duration) -> Result, move |res| { + wv.call_async_javascript_function(&wrapped, None, world, None, None::<&gio::Cancellable>, move |res| { use javascriptcore::ValueExt; let out = match res { Ok(v) if v.is_string() => Ok(v.to_str().to_string()), diff --git a/src/bookmarks.rs b/src/bookmarks.rs index 1519f53..b0f7e5e 100644 --- a/src/bookmarks.rs +++ b/src/bookmarks.rs @@ -1,9 +1,20 @@ //! Bookmark management for sovereign_browser //! //! Port of `bookmarks.c` / `bookmarks.h` from the C project. +//! +//! Also implements import/export of the Netscape bookmark HTML format +//! (``) used by Chromium, Chrome, Firefox +//! and most other browsers. use crate::db; +/// Name of the bookmark folder shown in the per-tab bookmark bar. +pub const BOOKMARKS_BAR_FOLDER: &str = "Bookmarks Bar"; + +/// Folder that receives imported bookmarks that sit at the top level of the +/// import file (our tree view only shows bookmarks that live in a folder). +const DEFAULT_IMPORT_FOLDER: &str = "General"; + /// Add a bookmark. pub fn bookmarks_add(title: &str, url: &str, folder: &str) -> Result> { let id = db::db_add_bookmark(title, url, folder)?; @@ -37,3 +48,454 @@ pub fn bookmarks_publish() { } // TODO: Build and sign a kind 30003 event, publish to bootstrap relays. } + +// ═══════════════════════════════════════════════════════════════════════ +// HTML export +// ═══════════════════════════════════════════════════════════════════════ + +#[derive(Default)] +struct ExportNode { + marks: Vec<(String, String, u64)>, // (title, url, added) + kids: Vec<(String, ExportNode)>, +} + +impl ExportNode { + fn child(&mut self, name: &str) -> &mut ExportNode { + let idx = match self.kids.iter().position(|(n, _)| n == name) { + Some(i) => i, + None => { + self.kids.push((name.to_string(), ExportNode::default())); + self.kids.len() - 1 + } + }; + &mut self.kids[idx].1 + } +} + +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(c), + } + } + out +} + +fn write_export_node(out: &mut String, node: &ExportNode, depth: usize, now: u64) { + let pad = " ".repeat(depth); + for (title, url, added) in &node.marks { + let label = if title.is_empty() { url } else { title }; + out.push_str(&format!( + "{}
{}\n", + pad, + html_escape(url), + added, + html_escape(label) + )); + } + for (name, kid) in &node.kids { + let is_bar = depth == 1 && name == BOOKMARKS_BAR_FOLDER; + if is_bar { + // Chromium recognises the bookmarks bar via this attribute. + out.push_str(&format!( + "{}

Bookmarks bar

\n", + pad, now, now + )); + } else { + out.push_str(&format!( + "{}

{}

\n", + pad, + now, + now, + html_escape(name) + )); + } + out.push_str(&format!("{}

\n", pad)); + write_export_node(out, kid, depth + 1, now); + out.push_str(&format!("{}

\n", pad)); + } +} + +/// Render all bookmarks as a Netscape/Chromium-compatible HTML document. +pub fn bookmarks_export_html() -> Result> { + let all = db::db_get_bookmarks()?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut root = ExportNode::default(); + for bm in &all { + let title = bm["title"].as_str().unwrap_or("").to_string(); + let url = bm["url"].as_str().unwrap_or("").to_string(); + if url.is_empty() { + continue; + } + let added = bm["created_at"].as_u64().unwrap_or(now); + let folder = bm["folder"].as_str().unwrap_or(""); + let mut node = &mut root; + for part in folder.split('/').filter(|p| !p.is_empty()) { + node = node.child(part); + } + node.marks.push((title, url, added)); + } + // Bookmarks bar first, like Chromium. + root.kids.sort_by_key(|(n, _)| n != BOOKMARKS_BAR_FOLDER); + + let mut out = String::new(); + out.push_str("\n"); + out.push_str("\n"); + out.push_str("\n"); + out.push_str("Bookmarks\n"); + out.push_str("

Bookmarks

\n"); + out.push_str("

\n"); + write_export_node(&mut out, &root, 1, now); + out.push_str("

\n"); + Ok(out) +} + +/// Export all bookmarks to an HTML file. Returns the number exported. +pub fn bookmarks_export_to_file(path: &std::path::Path) -> Result> { + let html = bookmarks_export_html()?; + let count = html.matches("

, +} + +/// Result of an import. +#[derive(Debug, Default, Clone, Copy)] +pub struct ImportSummary { + pub added: usize, + pub skipped: usize, +} + +fn html_unescape(s: &str) -> String { + if !s.contains('&') { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(pos) = rest.find('&') { + out.push_str(&rest[..pos]); + rest = &rest[pos..]; + let semi = rest.find(';').filter(|&i| i <= 10); + let decoded = semi.and_then(|end| { + let ent = &rest[1..end]; + let ch = match ent { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some(' '), + _ => { + if let Some(hex) = ent.strip_prefix("#x").or_else(|| ent.strip_prefix("#X")) { + u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) + } else if let Some(dec) = ent.strip_prefix('#') { + dec.parse::().ok().and_then(char::from_u32) + } else { + None + } + } + }; + ch.map(|c| (c, end)) + }); + match decoded { + Some((c, end)) => { + out.push(c); + rest = &rest[end + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +/// Find the `>` that closes a tag starting after `from`, honouring quotes. +fn find_tag_end(s: &str, from: usize) -> Option { + let bytes = s.as_bytes(); + let mut quote: Option = None; + let mut i = from; + while i < bytes.len() { + let b = bytes[i]; + match quote { + Some(q) => { + if b == q { + quote = None; + } + } + None => { + if b == b'"' || b == b'\'' { + quote = Some(b); + } else if b == b'>' { + return Some(i); + } + } + } + i += 1; + } + None +} + +/// Parse `name="value"` attributes (names lower-cased, values unescaped). +fn parse_attrs(s: &str) -> Vec<(String, String)> { + let mut attrs = Vec::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b'/') { + i += 1; + } + let name_start = i; + while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'=' && b[i] != b'/' { + i += 1; + } + if name_start == i { + break; + } + let name = s[name_start..i].to_ascii_lowercase(); + while i < b.len() && b[i].is_ascii_whitespace() { + i += 1; + } + let mut value = String::new(); + if i < b.len() && b[i] == b'=' { + i += 1; + while i < b.len() && b[i].is_ascii_whitespace() { + i += 1; + } + if i < b.len() && (b[i] == b'"' || b[i] == b'\'') { + let q = b[i]; + i += 1; + let vs = i; + while i < b.len() && b[i] != q { + i += 1; + } + value = html_unescape(&s[vs..i]); + i += 1; // closing quote + } else { + let vs = i; + while i < b.len() && !b[i].is_ascii_whitespace() { + i += 1; + } + value = html_unescape(&s[vs..i]); + } + } + attrs.push((name, value)); + } + attrs +} + +fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> { + attrs.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str()) +} + +/// Folder names cannot contain the path separator. +fn sanitize_folder_name(name: &str) -> String { + let n = name.replace('/', "-"); + let n = n.trim(); + if n.is_empty() { "Untitled".to_string() } else { n.to_string() } +} + +/// Parse a Netscape-format bookmark HTML document. +pub fn bookmarks_parse_html(input: &str) -> Vec { + // ASCII lower-casing preserves byte offsets, so we can search `lower` + // and slice `input` with the same indices. + let lower = input.to_ascii_lowercase(); + let mut out = Vec::new(); + // One entry per open
; Some(name) when it belongs to a folder. + let mut stack: Vec> = Vec::new(); + let mut pending_folder: Option = None; + + let mut pos = 0; + while let Some(rel) = input[pos..].find('<') { + let lt = pos + rel; + // Comments / doctype. + if input[lt..].starts_with("").map(|e| lt + 4 + e + 3).unwrap_or(input.len()); + continue; + } + let Some(gt) = find_tag_end(input, lt + 1) else { break }; + let inner = &input[lt + 1..gt]; + let name_end = inner + .find(|c: char| c.is_ascii_whitespace() || c == '/') + .unwrap_or(inner.len()); + let closing = inner.starts_with('/'); + let tag = if closing { + let t = &inner[1..]; + let e = t.find(|c: char| c.is_ascii_whitespace()).unwrap_or(t.len()); + t[..e].to_ascii_lowercase() + } else { + inner[..name_end].to_ascii_lowercase() + }; + pos = gt + 1; + + match (tag.as_str(), closing) { + ("dl", false) => stack.push(pending_folder.take()), + ("dl", true) => { + stack.pop(); + } + ("h3", false) => { + let attrs = parse_attrs(&inner[name_end..]); + let close = lower[pos..].find(" { + let attrs = parse_attrs(&inner[name_end..]); + let close = lower[pos..].find(" = stack.iter().filter_map(|s| s.as_deref()).collect(); + let folder = if folder.is_empty() { + DEFAULT_IMPORT_FOLDER.to_string() + } else { + folder.join("/") + }; + out.push(ParsedBookmark { + title, + url, + folder, + added: attr(&attrs, "add_date").and_then(|v| v.parse::().ok()), + }); + } + _ => {} + } + } + out +} + +/// Import bookmarks from HTML text. Bookmarks already present (same URL in +/// the same folder) are skipped. +pub fn bookmarks_import_html(html: &str) -> Result> { + let parsed = bookmarks_parse_html(html); + let mut existing: std::collections::HashSet<(String, String)> = db::db_get_bookmarks()? + .iter() + .map(|b| { + ( + b["folder"].as_str().unwrap_or("").to_string(), + b["url"].as_str().unwrap_or("").to_string(), + ) + }) + .collect(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut summary = ImportSummary::default(); + for bm in parsed { + let key = (bm.folder.clone(), bm.url.clone()); + if existing.contains(&key) { + summary.skipped += 1; + continue; + } + // Some exporters write add dates in microseconds or 0. + let added = match bm.added { + Some(t) if t > 0 && t < 100_000_000_000 => t, + _ => now, + }; + db::db_add_bookmark_at(&bm.title, &bm.url, &bm.folder, added)?; + existing.insert(key); + summary.added += 1; + } + if summary.added > 0 { + bookmarks_publish(); + } + Ok(summary) +} + +/// Import bookmarks from an HTML file. +pub fn bookmarks_import_from_file(path: &std::path::Path) -> Result> { + let bytes = std::fs::read(path)?; + let html = String::from_utf8_lossy(&bytes); + bookmarks_import_html(&html) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" + +Bookmarks +

Bookmarks

+

+

Bookmarks bar

+

+

Example & Co +

Work/Stuff

+

+

Rust +

+

+

Other bookmarks

+

+

Other +

+

Top +

+"#; + + #[test] + fn parses_chromium_export() { + let v = bookmarks_parse_html(SAMPLE); + assert_eq!(v.len(), 4); + assert_eq!(v[0].folder, "Bookmarks Bar"); + assert_eq!(v[0].url, "https://example.com/?a=1&b=2"); + assert_eq!(v[0].title, "Example & Co"); + assert_eq!(v[0].added, Some(1600000000)); + assert_eq!(v[1].folder, "Bookmarks Bar/Work-Stuff"); + assert_eq!(v[2].folder, "Other bookmarks"); + assert_eq!(v[3].folder, "General"); + } + + #[test] + fn escape_roundtrip() { + assert_eq!(html_unescape(&html_escape("a&b\"d\"")), "a&b\"d\""); + assert_eq!(html_unescape("AB&unknown;"), "AB&unknown;"); + } +} diff --git a/src/db.rs b/src/db.rs index a560cf0..ffea381 100644 --- a/src/db.rs +++ b/src/db.rs @@ -724,14 +724,20 @@ pub fn db_add_bookmark( title: &str, url: &str, folder: &str, +) -> Result> { + db_add_bookmark_at(title, url, folder, now_secs()) +} + +/// Add a bookmark with an explicit creation timestamp (unix seconds). +/// Used by the HTML bookmark importer to preserve original add dates. +pub fn db_add_bookmark_at( + title: &str, + url: &str, + folder: &str, + now: u64, ) -> Result> { let db = DB.lock().unwrap(); if let Some(ref conn) = *db { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - conn.execute( "INSERT INTO bookmarks (title, url, folder, position, created_at, updated_at) VALUES (?1, ?2, ?3, 0, ?4, ?4)", diff --git a/src/key_handler.rs b/src/key_handler.rs index 0f61d81..9e82486 100644 --- a/src/key_handler.rs +++ b/src/key_handler.rs @@ -152,6 +152,7 @@ fn run_action(action: &str) -> bool { "show_history" => tab_manager::tab_manager_open_internal("sovereign://profile"), "show_bookmarks" => tab_manager::tab_manager_open_internal("sovereign://bookmarks"), "quit" => { + // Explicit quit exits the whole app (all windows). crate::session::session_save(); gtk::main_quit(); } diff --git a/src/main.rs b/src/main.rs index a76b470..d632331 100644 --- a/src/main.rs +++ b/src/main.rs @@ -413,7 +413,12 @@ fn main() { }); window.connect_delete_event(|_, _| { + // Closing the main window must only close that window when other + // windows are still open; the app quits with the last window. session::session_save(); + if tab_manager::tab_manager_handle_main_window_close() { + return glib::Propagation::Stop; + } gtk::main_quit(); glib::Propagation::Proceed }); diff --git a/src/nostr_bridge.rs b/src/nostr_bridge.rs index 2a1f2a6..4bd6fdf 100644 --- a/src/nostr_bridge.rs +++ b/src/nostr_bridge.rs @@ -606,11 +606,21 @@ fn handle_bookmarks_api(request: &URISchemeRequest, sub: &str) { } respond_redirect(request, "sovereign://bookmarks"); } + "import" => { + // Opens a native file chooser (import mutations refresh the UI + // themselves once the dialog completes). + glib::idle_add_once(crate::tab_manager::tab_manager_import_bookmarks); + respond_json(request, "{\"status\":\"ok\"}"); + } + "export" => { + glib::idle_add_once(crate::tab_manager::tab_manager_export_bookmarks); + respond_json(request, "{\"status\":\"ok\"}"); + } _ => respond_error_json(request, 404, "Unknown bookmarks route"), } // Mutations change what the per-tab bookmark bars show. - if route != "list" { + if route != "list" && route != "import" && route != "export" { glib::idle_add_once(crate::tab_manager::tab_manager_refresh_bookmark_bars); } } diff --git a/src/nostr_inject.rs b/src/nostr_inject.rs index 60c26e5..26c1e17 100644 --- a/src/nostr_inject.rs +++ b/src/nostr_inject.rs @@ -27,49 +27,55 @@ const NOSTR_SHIM_JS: &str = r#" (function() { 'use strict'; - var BRIDGE_BASE = 'sovereign://nostr/'; + /* The actual sovereign:// XHR runs in an isolated script world (see + * NOSTR_BRIDGE_RELAY_JS). Pages with a strict Content-Security-Policy + * (e.g. jumble.social: connect-src without sovereign:) block the XHR when + * issued from the page's own world; isolated worlds are exempt. This main + * world shim just relays requests/responses over DOM CustomEvents, using + * JSON strings as details (objects don't cross worlds). */ + var REQ_EVT = '__sb_nostr_req'; + var RES_EVT = '__sb_nostr_res'; + var pending = {}; + var nextId = 1; + + document.addEventListener(RES_EVT, function(ev) { + var msg; + try { msg = JSON.parse(ev.detail); } catch(e) { return; } + var p = pending[msg.id]; + if (!p) return; + delete pending[msg.id]; + if (msg.failure) { + p.reject(new Error('Bridge request failed: ' + msg.failure)); + return; + } + var text = msg.text; + if (!text) { + p.reject(new Error('Bridge returned empty response')); + return; + } + var data; + try { + data = JSON.parse(text); + } catch(e) { + p.reject(new Error('Bridge returned invalid JSON: ' + text)); + return; + } + if (data.error !== undefined) { + p.reject(new Error(data.message || (typeof data.error === 'string' ? data.error : ('Error code ' + data.error)))); + return; + } + p.resolve(data); + }); function bridgeCall(method, bodyObj) { - var url = BRIDGE_BASE + method; - - if (bodyObj !== undefined && bodyObj !== null) { - /* Encode the body as a query parameter since WebKitGTK's custom - * scheme handler doesn't easily expose POST bodies. */ - var bodyJson = JSON.stringify(bodyObj); - url += '?body=' + encodeURIComponent(bodyJson); - } - - /* Use synchronous XMLHttpRequest — WebKitGTK's fetch() and async XHR - * enforce CORS on custom schemes even when registered as secure, but - * synchronous XHR to a secure custom scheme works without CORS headers. - * The bridge is local (sovereign:// URI scheme handler in Rust), so the - * call is effectively instant. */ return new Promise(function(resolve, reject) { - try { - var xhr = new XMLHttpRequest(); - xhr.open('GET', url, false); /* synchronous */ - xhr.send(); - var text = xhr.responseText; - if (!text) { - reject(new Error('Bridge returned empty response')); - return; - } - var data; - try { - data = JSON.parse(text); - } catch(e) { - reject(new Error('Bridge returned invalid JSON: ' + text)); - return; - } - if (data.error !== undefined) { - var msg = data.message || ('Error code ' + data.error); - reject(new Error(msg)); - return; - } - resolve(data); - } catch(e) { - reject(new Error('Bridge request failed: ' + e.message)); - } + var id = nextId++; + pending[id] = { resolve: resolve, reject: reject }; + var bodyJson = (bodyObj !== undefined && bodyObj !== null) + ? JSON.stringify(bodyObj) : null; + document.dispatchEvent(new CustomEvent(REQ_EVT, { + detail: JSON.stringify({ id: id, method: method, body: bodyJson }) + })); }); } @@ -132,6 +138,97 @@ const NOSTR_SHIM_JS: &str = r#" })(); "#; +/// Name of the isolated script world that talks to sovereign://nostr/. +const BRIDGE_WORLD: &str = "sovereign-nostr-bridge"; + +/// Relay running in an isolated world (exempt from the page's CSP). Receives +/// requests from the main-world shim via DOM events, performs the synchronous +/// XHR to `sovereign://nostr/` and replies via a DOM event. +const NOSTR_BRIDGE_RELAY_JS: &str = r#" +(function() { + 'use strict'; + var REQ_EVT = '__sb_nostr_req'; + var RES_EVT = '__sb_nostr_res'; + var METHODS = { + getPublicKey: 1, signEvent: 1, getRelays: 1, + nip04Encrypt: 1, nip04Decrypt: 1, nip44Encrypt: 1, nip44Decrypt: 1 + }; + + function reply(obj) { + document.dispatchEvent(new CustomEvent(RES_EVT, { detail: JSON.stringify(obj) })); + } + + document.addEventListener(REQ_EVT, function(ev) { + var req; + try { req = JSON.parse(ev.detail); } catch(e) { return; } + if (!req || !METHODS.hasOwnProperty(req.method)) { + reply({ id: req && req.id, failure: 'unknown method' }); + return; + } + var url = 'sovereign://nostr/' + req.method; + if (req.body !== null && req.body !== undefined) { + /* WebKitGTK's custom scheme handler doesn't easily expose POST + * bodies, so the body travels as a query parameter. */ + url += '?body=' + encodeURIComponent(req.body); + } + try { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); /* synchronous, see above */ + xhr.send(); + reply({ id: req.id, text: xhr.responseText }); + } catch(e) { + reply({ id: req.id, failure: String(e && e.message || e) }); + } + }); +})(); +"#; + +/// Add the NIP-07 main-world shim and the isolated-world relay to `manager`. +fn add_nostr_scripts(manager: &UserContentManager) { + let shim = UserScript::new( + NOSTR_SHIM_JS, + UserContentInjectedFrames::AllFrames, + UserScriptInjectionTime::Start, + &[], // allow_list + &[], // block_list + ); + manager.add_script(&shim); + + let relay = UserScript::for_world( + NOSTR_BRIDGE_RELAY_JS, + UserContentInjectedFrames::AllFrames, + UserScriptInjectionTime::Start, + BRIDGE_WORLD, + &[], + &[], + ); + manager.add_script(&relay); + + add_scrollbar_visibility_fix(manager); +} + +/// Sites such as jumble.social replace the native scrollbar with a custom +/// `::-webkit-scrollbar` whose thumb is `hsl(var(--muted) / .5)` — on a light +/// theme that is ~3% off the page background, so the scrollbar is effectively +/// invisible. Force a mid-grey, semi-transparent thumb that is legible on both +/// light and dark pages. `!important` in a USER-level sheet beats author rules. +/// The `-thumb` rules only take effect where the page already opted into +/// custom scrollbars, so native scrollbars elsewhere are unaffected. +fn add_scrollbar_visibility_fix(manager: &UserContentManager) { + const CSS: &str = "\ + ::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.55) !important; \ + border-radius: 4px !important; min-height: 32px; }\n\ + ::-webkit-scrollbar-thumb:hover { background: rgba(128,128,128,0.8) !important; }\n"; + let sheet = UserStyleSheet::new( + CSS, + UserContentInjectedFrames::AllFrames, + UserStyleLevel::User, + &[], + &[], + ); + manager.add_style_sheet(&sheet); +} + /// Perf probe preamble — skips sovereign:// and about: pages, sets tab index. /// The tab index is baked in at injection time as a literal. fn perf_probe_preamble(tab_index: i32) -> String { @@ -161,14 +258,7 @@ const PERF_PROBE_JS: &str = include_str!("../www/js/perf-probe.js"); /// The tab_index is used by the probe to identify which tab is reporting. pub fn nostr_inject_create_content_manager() -> UserContentManager { let manager = UserContentManager::new(); - let script = UserScript::new( - NOSTR_SHIM_JS, - UserContentInjectedFrames::AllFrames, - UserScriptInjectionTime::Start, - &[], // allow_list - &[], // block_list - ); - manager.add_script(&script); + add_nostr_scripts(&manager); manager } @@ -177,15 +267,8 @@ pub fn nostr_inject_create_content_manager() -> UserContentManager { pub fn nostr_inject_create_content_manager_with_probe(tab_index: i32) -> UserContentManager { let manager = UserContentManager::new(); - // NIP-07 shim. - let script = UserScript::new( - NOSTR_SHIM_JS, - UserContentInjectedFrames::AllFrames, - UserScriptInjectionTime::Start, - &[], // allow_list - &[], // block_list - ); - manager.add_script(&script); + // NIP-07 shim + isolated-world bridge relay. + add_nostr_scripts(&manager); // Perf probe preamble (skips sovereign:// pages, sets tab index). // Top frame only — mirrors the C version (perf_probe.c:67). Injecting diff --git a/src/tab_manager.rs b/src/tab_manager.rs index e5257e7..1e9a5ef 100644 --- a/src/tab_manager.rs +++ b/src/tab_manager.rs @@ -158,10 +158,65 @@ fn set_active_notebook(notebook: >k::Notebook) { /// Revert the active notebook to the main window's notebook. fn revert_active_notebook_to_main() { let main = tab_manager_get_main_notebook(); + + // If the main window has been closed (hidden while other windows are + // still open), fall back to a remaining auxiliary window instead. + if !main_window_is_visible() { + let fallback = G_AUX_WINDOWS.lock().unwrap().iter().find_map(|g| { + g.get_ref() + .child() + .and_then(|c| c.downcast::().ok()) + }); + if let Some(nb) = fallback { + set_active_notebook(&nb); + return; + } + } + *G_ACTIVE_NOTEBOOK.lock().unwrap() = main.map(glib::thread_guard::ThreadGuard::new); } +/// Whether the main window is currently shown. +fn main_window_is_visible() -> bool { + tab_manager_get_main_window() + .map(|w| w.is_visible()) + .unwrap_or(false) +} + +/// Handle a close request on the main window. +/// +/// Closing one window must only close that window. If other windows are +/// still open, the main window's tabs are closed and the window is hidden +/// (its widgets stay alive because the rest of the app uses the main +/// notebook/window) and `true` is returned so the caller keeps the app +/// running. If it is the only window, `false` is returned and the caller +/// should quit as usual. +pub fn tab_manager_handle_main_window_close() -> bool { + if G_AUX_WINDOWS.lock().unwrap().is_empty() { + return false; + } + let Some(main_nb) = tab_manager_get_main_notebook() else { return false }; + + // Close every tab that lives in the main window. + let ids: Vec = G_TAB_WIDGETS + .lock() + .unwrap() + .iter() + .filter(|(_, (nb, _))| nb.get_ref() == &main_nb) + .map(|(id, _)| *id) + .collect(); + for id in ids { + tab_manager_close_tab(&main_nb, id); + } + + if let Some(w) = tab_manager_get_main_window() { + w.hide(); + } + revert_active_notebook_to_main(); + true +} + /// Register a tab's notebook + webview so close/reload/duplicate can resolve /// the tab's own notebook (which may belong to an auxiliary window). fn register_tab_widgets(tab_id: i32, notebook: >k::Notebook, webview: &WebView) { @@ -916,6 +971,14 @@ pub fn tab_manager_close_tab(notebook: >k::Notebook, tab_id: i32) { if let Some(win) = nb.toplevel().and_then(|w| w.downcast::().ok()) { win.close(); } + } else if is_main && nb.n_pages() == 0 && !G_AUX_WINDOWS.lock().unwrap().is_empty() { + // The main window's last tab closed while other windows are + // open: close just this window (hide it; see + // tab_manager_handle_main_window_close). + if let Some(w) = tab_manager_get_main_window() { + w.hide(); + } + revert_active_notebook_to_main(); } // If no tabs remain anywhere and not suppressed, quit. @@ -1186,6 +1249,125 @@ pub fn tab_manager_open_file() { } } +/// Show a simple modal message dialog on the active window. +fn show_message_dialog(kind: gtk::MessageType, text: &str) { + let parent = tab_manager_get_active_notebook() + .and_then(|nb| nb.toplevel()) + .and_then(|w| w.downcast::().ok()); + let dlg = gtk::MessageDialog::new( + parent.as_ref(), + gtk::DialogFlags::MODAL, + kind, + gtk::ButtonsType::Ok, + text, + ); + dlg.run(); + unsafe { dlg.destroy(); } +} + +/// Add an "HTML files" filter to a file chooser. +fn add_html_filter(chooser: >k::FileChooserDialog) { + let filter = gtk::FileFilter::new(); + filter.set_name(Some("HTML bookmark files (*.html, *.htm)")); + filter.add_pattern("*.html"); + filter.add_pattern("*.htm"); + chooser.add_filter(filter); + let all = gtk::FileFilter::new(); + all.set_name(Some("All files")); + all.add_pattern("*"); + chooser.add_filter(all); +} + +/// Reload the active tab if it is showing the bookmarks page. +fn reload_bookmarks_page_if_open() { + if let Some(nb) = tab_manager_get_active_notebook() { + if let Some(wv) = tab_manager_get_active_webview(&nb) { + if wv.uri().map(|u| u.starts_with("sovereign://bookmarks")).unwrap_or(false) { + wv.reload(); + } + } + } +} + +/// Import bookmarks from a Chromium/Netscape-format HTML file chosen by +/// the user. +pub fn tab_manager_import_bookmarks() { + let parent = tab_manager_get_active_notebook() + .and_then(|nb| nb.toplevel()) + .and_then(|w| w.downcast::().ok()); + let chooser = gtk::FileChooserDialog::new( + Some("Import Bookmarks"), + parent.as_ref(), + gtk::FileChooserAction::Open, + ); + chooser.add_button("Cancel", gtk::ResponseType::Cancel); + chooser.add_button("Import", gtk::ResponseType::Accept); + add_html_filter(&chooser); + let path = if chooser.run() == gtk::ResponseType::Accept { + chooser.filename() + } else { + None + }; + unsafe { chooser.destroy(); } + + let Some(path) = path else { return }; + match bookmarks::bookmarks_import_from_file(&path) { + Ok(s) => { + println!("[bookmarks] Imported {} bookmarks ({} duplicates skipped) from {}", + s.added, s.skipped, path.display()); + tab_manager_refresh_bookmark_bars(); + reload_bookmarks_page_if_open(); + show_message_dialog( + gtk::MessageType::Info, + &format!("Imported {} bookmark(s). {} duplicate(s) skipped.", s.added, s.skipped), + ); + } + Err(e) => { + eprintln!("[bookmarks] Import failed: {}", e); + show_message_dialog(gtk::MessageType::Error, &format!("Import failed: {}", e)); + } + } +} + +/// Export all bookmarks to a Chromium-compatible HTML file chosen by the +/// user. +pub fn tab_manager_export_bookmarks() { + let parent = tab_manager_get_active_notebook() + .and_then(|nb| nb.toplevel()) + .and_then(|w| w.downcast::().ok()); + let chooser = gtk::FileChooserDialog::new( + Some("Export Bookmarks"), + parent.as_ref(), + gtk::FileChooserAction::Save, + ); + chooser.add_button("Cancel", gtk::ResponseType::Cancel); + chooser.add_button("Export", gtk::ResponseType::Accept); + chooser.set_do_overwrite_confirmation(true); + chooser.set_current_name("bookmarks.html"); + add_html_filter(&chooser); + let path = if chooser.run() == gtk::ResponseType::Accept { + chooser.filename() + } else { + None + }; + unsafe { chooser.destroy(); } + + let Some(path) = path else { return }; + match bookmarks::bookmarks_export_to_file(&path) { + Ok(n) => { + println!("[bookmarks] Exported {} bookmarks to {}", n, path.display()); + show_message_dialog( + gtk::MessageType::Info, + &format!("Exported {} bookmark(s) to {}", n, path.display()), + ); + } + Err(e) => { + eprintln!("[bookmarks] Export failed: {}", e); + show_message_dialog(gtk::MessageType::Error, &format!("Export failed: {}", e)); + } + } +} + /// Create a new blank window. pub fn tab_manager_new_window_blank() { // Get the context from the active window's active webview. @@ -1244,8 +1426,17 @@ pub fn tab_manager_new_window(url: Option<&str>, ctx: &WebContext) -> Option = { let widgets = G_TAB_WIDGETS.lock().unwrap(); @@ -1266,6 +1457,10 @@ pub fn tab_manager_new_window(url: Option<&str>, ctx: &WebContext) -> Option gtk::MenuButton { let manage_item = gtk::MenuItem::with_label("Manage Bookmarks…"); manage_item.connect_activate(|_| tab_manager_open_internal("sovereign://bookmarks")); m.append(&manage_item); + let import_item = gtk::MenuItem::with_label("Import Bookmarks from HTML…"); + import_item.connect_activate(|_| tab_manager_import_bookmarks()); + m.append(&import_item); + let export_item = gtk::MenuItem::with_label("Export Bookmarks to HTML…"); + export_item.connect_activate(|_| tab_manager_export_bookmarks()); + m.append(&export_item); m.show_all(); }); let item_bookmarks = gtk::MenuItem::with_label("Bookmarks"); diff --git a/src/version.rs b/src/version.rs index 72e1250..c1718c9 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,7 +1,7 @@ //! Version information for sovereign_browser /// The current version of sovereign_browser (with leading 'v'). -pub const VERSION: &str = "v0.0.11"; +pub const VERSION: &str = "v0.0.12"; /// Major version number. pub const VERSION_MAJOR: u32 = 0; @@ -10,4 +10,4 @@ pub const VERSION_MAJOR: u32 = 0; pub const VERSION_MINOR: u32 = 0; /// Patch version number. -pub const VERSION_PATCH: u32 = 11; +pub const VERSION_PATCH: u32 = 12; diff --git a/www/bookmarks.html b/www/bookmarks.html index 9e91e36..f12c411 100644 --- a/www/bookmarks.html +++ b/www/bookmarks.html @@ -28,6 +28,11 @@ +

+ + +
+

Bookmarks

diff --git a/www/bookmarks.js b/www/bookmarks.js index 3b9df23..0e18ddd 100644 --- a/www/bookmarks.js +++ b/www/bookmarks.js @@ -291,6 +291,16 @@ function addBookmark() { '&title=' + title; } +/* Import/export use native file dialogs opened by the browser; the + * bookmarks page reloads itself after a successful import. */ +function importBookmarks() { + sovereignGet('sovereign://bookmarks/import?_=' + Date.now()).catch(function() {}); +} + +function exportBookmarks() { + sovereignGet('sovereign://bookmarks/export?_=' + Date.now()).catch(function() {}); +} + function createDir() { var name = encodeURIComponent(document.getElementById('new-path').value); if (!name) { alert('Folder path is required'); return; }