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

This commit is contained in:
Laan Tungir
2026-09-29 09:46:24 -04:00
parent a7d3084c46
commit 786f367006
13 changed files with 870 additions and 69 deletions
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1 +1 @@
0.0.11
0.0.12
+20 -2
View File
@@ -278,6 +278,23 @@ fn with_active_webview(f: impl FnOnce(Option<webkit2gtk::WebView>) + 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<serde_json::Value, String> {
// 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<serde_json::Value, String> {
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<serde_json
"try {{ let __r = (0, eval)({src}); if (__r && typeof __r.then === 'function') __r = await __r; \
return JSON.stringify(__r === undefined ? null : __r, (k, v) => \
(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::<Result<String, String>>();
@@ -299,7 +317,7 @@ pub fn eval_js_sync(js: &str, timeout: std::time::Duration) -> Result<serde_json
let _ = tx.send(Err("No active tab".to_string()));
return;
};
wv.call_async_javascript_function(&wrapped, None, None, None, None::<&gio::Cancellable>, 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()),
+462
View File
@@ -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
//! (`<!DOCTYPE NETSCAPE-Bookmark-file-1>`) 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<i64, Box<dyn std::error::Error>> {
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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => 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!(
"{}<DT><A HREF=\"{}\" ADD_DATE=\"{}\">{}</A>\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!(
"{}<DT><H3 ADD_DATE=\"{}\" LAST_MODIFIED=\"{}\" PERSONAL_TOOLBAR_FOLDER=\"true\">Bookmarks bar</H3>\n",
pad, now, now
));
} else {
out.push_str(&format!(
"{}<DT><H3 ADD_DATE=\"{}\" LAST_MODIFIED=\"{}\">{}</H3>\n",
pad,
now,
now,
html_escape(name)
));
}
out.push_str(&format!("{}<DL><p>\n", pad));
write_export_node(out, kid, depth + 1, now);
out.push_str(&format!("{}</DL><p>\n", pad));
}
}
/// Render all bookmarks as a Netscape/Chromium-compatible HTML document.
pub fn bookmarks_export_html() -> Result<String, Box<dyn std::error::Error>> {
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("<!DOCTYPE NETSCAPE-Bookmark-file-1>\n");
out.push_str("<!-- This is an automatically generated file.\n");
out.push_str(" It will be read and overwritten.\n");
out.push_str(" DO NOT EDIT! -->\n");
out.push_str("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n");
out.push_str("<TITLE>Bookmarks</TITLE>\n");
out.push_str("<H1>Bookmarks</H1>\n");
out.push_str("<DL><p>\n");
write_export_node(&mut out, &root, 1, now);
out.push_str("</DL><p>\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<usize, Box<dyn std::error::Error>> {
let html = bookmarks_export_html()?;
let count = html.matches("<DT><A ").count();
std::fs::write(path, html)?;
Ok(count)
}
// ═══════════════════════════════════════════════════════════════════════
// HTML import
// ═══════════════════════════════════════════════════════════════════════
/// A bookmark parsed from an HTML file.
#[derive(Debug, PartialEq)]
pub struct ParsedBookmark {
pub title: String,
pub url: String,
pub folder: String,
pub added: Option<u64>,
}
/// 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::<u32>().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<usize> {
let bytes = s.as_bytes();
let mut quote: Option<u8> = 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<ParsedBookmark> {
// 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 <DL>; Some(name) when it belongs to a folder.
let mut stack: Vec<Option<String>> = Vec::new();
let mut pending_folder: Option<String> = None;
let mut pos = 0;
while let Some(rel) = input[pos..].find('<') {
let lt = pos + rel;
// Comments / doctype.
if input[lt..].starts_with("<!--") {
pos = input[lt + 4..].find("-->").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("</h3").map(|e| pos + e).unwrap_or(input.len());
let raw = html_unescape(input[pos..close].trim());
let at_top = stack.iter().all(|s| s.is_none());
let toolbar_attr = attr(&attrs, "personal_toolbar_folder")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let lname = raw.to_lowercase();
let is_bar = at_top
&& (toolbar_attr
|| lname == "bookmarks bar"
|| lname == "bookmarks toolbar"
|| lname == "favorites bar");
pending_folder = Some(if is_bar {
BOOKMARKS_BAR_FOLDER.to_string()
} else {
sanitize_folder_name(&raw)
});
pos = close;
}
("a", false) => {
let attrs = parse_attrs(&inner[name_end..]);
let close = lower[pos..].find("</a").map(|e| pos + e).unwrap_or(input.len());
let title = html_unescape(input[pos..close].trim());
pos = close;
let url = attr(&attrs, "href").unwrap_or("").trim().to_string();
if url.is_empty() || url.starts_with("place:") {
continue;
}
let folder: Vec<&str> = 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::<u64>().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<ImportSummary, Box<dyn std::error::Error>> {
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<ImportSummary, Box<dyn std::error::Error>> {
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#"<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><H3 ADD_DATE="1600000000" LAST_MODIFIED="1600000001" PERSONAL_TOOLBAR_FOLDER="true">Bookmarks bar</H3>
<DL><p>
<DT><A HREF="https://example.com/?a=1&amp;b=2" ADD_DATE="1600000000">Example &amp; Co</A>
<DT><H3 ADD_DATE="1600000000">Work/Stuff</H3>
<DL><p>
<DT><A HREF="https://rust-lang.org/">Rust</A>
</DL><p>
</DL><p>
<DT><H3>Other bookmarks</H3>
<DL><p>
<DT><A HREF="https://o.example/">Other</A>
</DL><p>
<DT><A HREF="https://top.example/">Top</A>
</DL><p>
"#;
#[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<c>\"d\"")), "a&b<c>\"d\"");
assert_eq!(html_unescape("&#65;&#x42;&unknown;"), "AB&unknown;");
}
}
+11 -5
View File
@@ -724,14 +724,20 @@ pub fn db_add_bookmark(
title: &str,
url: &str,
folder: &str,
) -> Result<i64, Box<dyn std::error::Error>> {
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<i64, Box<dyn std::error::Error>> {
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)",
+1
View File
@@ -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();
}
+5
View File
@@ -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
});
+11 -1
View File
@@ -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);
}
}
+140 -57
View File
@@ -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/<method>` 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
+201
View File
@@ -158,10 +158,65 @@ fn set_active_notebook(notebook: &gtk::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::<gtk::Notebook>().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<i32> = 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: &gtk::Notebook, webview: &WebView) {
@@ -916,6 +971,14 @@ pub fn tab_manager_close_tab(notebook: &gtk::Notebook, tab_id: i32) {
if let Some(win) = nb.toplevel().and_then(|w| w.downcast::<gtk::Window>().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::<gtk::Window>().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: &gtk::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::<gtk::Window>().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::<gtk::Window>().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<Web
window.connect_destroy(move |_| {
let mut aux = G_AUX_WINDOWS.lock().unwrap();
aux.retain(|g| g.get_ref() != &window_weak);
let no_aux_left = aux.is_empty();
drop(aux);
// If the main window was already closed, this was the last open
// window: save the session (before this window's tabs are dropped
// from the state) and quit.
let last_window = no_aux_left && !main_window_is_visible();
if last_window {
crate::session::session_save();
}
// Remove tabs that belonged to this window's notebook.
let orphan_ids: Vec<i32> = {
let widgets = G_TAB_WIDGETS.lock().unwrap();
@@ -1266,6 +1457,10 @@ pub fn tab_manager_new_window(url: Option<&str>, ctx: &WebContext) -> Option<Web
}
revert_active_notebook_to_main();
if last_window {
gtk::main_quit();
}
});
// Register this window as an auxiliary window.
@@ -1860,6 +2055,12 @@ fn build_hamburger_menu() -> 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");
+2 -2
View File
@@ -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;
+5
View File
@@ -28,6 +28,11 @@
</div>
</div>
<div class="form">
<button class="btn" onclick="importBookmarks()">Import from HTML…</button>
<button class="btn" onclick="exportBookmarks()">Export to HTML…</button>
</div>
<h2>Bookmarks</h2>
<div id="bm-tree"></div>
+10
View File
@@ -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; }