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:
+1
-1
@@ -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"
|
||||
|
||||
+20
-2
@@ -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()),
|
||||
|
||||
@@ -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("&"),
|
||||
'<' => 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!(
|
||||
"{}<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&b=2" ADD_DATE="1600000000">Example & 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("AB&unknown;"), "AB&unknown;");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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::<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: >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::<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: >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::<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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user