940 lines
39 KiB
Rust
940 lines
39 KiB
Rust
//! sovereign:// URI scheme handler for window.nostr
|
|
//!
|
|
//! Port of `nostr_bridge.c` from the C project.
|
|
|
|
use glib::Bytes;
|
|
use gio::MemoryInputStream;
|
|
use gio::prelude::InputStreamExtManual;
|
|
use nostr_signer::traits::NostrSigner;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use once_cell::sync::Lazy;
|
|
use webkit2gtk::*;
|
|
|
|
use crate::settings;
|
|
use crate::db;
|
|
|
|
static G_BRIDGE: Lazy<Mutex<BridgeState>> = Lazy::new(|| {
|
|
Mutex::new(BridgeState {
|
|
signer: None,
|
|
pubkey_hex: String::new(),
|
|
readonly: true,
|
|
})
|
|
});
|
|
|
|
struct BridgeState {
|
|
signer: Option<Arc<dyn NostrSigner>>,
|
|
pubkey_hex: String,
|
|
readonly: bool,
|
|
}
|
|
|
|
pub fn nostr_bridge_init() {}
|
|
|
|
pub fn nostr_bridge_set_signer(signer: Option<Arc<dyn NostrSigner>>, pubkey_hex: &str, readonly: bool) {
|
|
let mut state = G_BRIDGE.lock().unwrap();
|
|
state.signer = signer;
|
|
state.pubkey_hex = pubkey_hex.to_string();
|
|
state.readonly = readonly;
|
|
}
|
|
|
|
pub fn nostr_bridge_register(
|
|
ctx: &WebContext,
|
|
signer: Option<Arc<dyn NostrSigner>>,
|
|
pubkey_hex: &str,
|
|
readonly: bool,
|
|
) {
|
|
nostr_bridge_set_signer(signer, pubkey_hex, readonly);
|
|
ctx.register_uri_scheme("sovereign", |request| {
|
|
handle_sovereign_scheme(request);
|
|
});
|
|
}
|
|
|
|
fn handle_sovereign_scheme(request: &URISchemeRequest) {
|
|
let uri = request.uri().unwrap_or_default();
|
|
let path = if let Some(stripped) = uri.strip_prefix("sovereign://") {
|
|
stripped
|
|
} else {
|
|
respond_error_json(request, 400, "Invalid URI");
|
|
return;
|
|
};
|
|
|
|
// Strip query string for routing (e.g. "profile/data?_=123").
|
|
let route = path.split('?').next().unwrap_or(path);
|
|
|
|
// ── nostr API ───────────────────────────────────────────────────
|
|
if route.starts_with("nostr/") {
|
|
handle_nostr_api(request, &route[6..]);
|
|
return;
|
|
}
|
|
|
|
// ── Settings ───────────────────────────────────────────────────
|
|
if route.starts_with("settings/set") {
|
|
let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
|
|
handle_settings_set(request, query);
|
|
return;
|
|
}
|
|
if route == "settings/config" {
|
|
handle_settings_config_json(request);
|
|
return;
|
|
}
|
|
|
|
// ── Bookmarks REST API ─────────────────────────────────────────
|
|
if route.starts_with("bookmarks/") {
|
|
handle_bookmarks_api(request, &route[10..]);
|
|
return;
|
|
}
|
|
|
|
// ── Processes REST API ─────────────────────────────────────────
|
|
if route.starts_with("processes/") {
|
|
handle_processes_api(request, &route[10..]);
|
|
return;
|
|
}
|
|
|
|
// ── Profile data ───────────────────────────────────────────────
|
|
if route == "profile/data" {
|
|
handle_profile_data_json(request);
|
|
return;
|
|
}
|
|
|
|
// ── QR code generation ─────────────────────────────────────────
|
|
if route == "qr" || route.starts_with("qr?") {
|
|
handle_qr(request, &path);
|
|
return;
|
|
}
|
|
|
|
// ── Security redirects to settings ─────────────────────────────
|
|
if route == "security" {
|
|
respond_html(request, &crate::embedded_web_content::get_security_html());
|
|
return;
|
|
}
|
|
|
|
// ── Embedded assets (HTML, CSS, JS, vendor) ────────────────────
|
|
if let Some((content, mime)) = crate::embedded_web_content::get_content_with_mime(route) {
|
|
respond_bytes(request, &content, mime);
|
|
return;
|
|
}
|
|
|
|
respond_error_json(request, 404, "Not found");
|
|
}
|
|
|
|
/// Handle sovereign://processes/* REST API.
|
|
/// Routes: list, tabs, probe-report, tab_probe, tab_action.
|
|
fn handle_processes_api(request: &URISchemeRequest, sub: &str) {
|
|
let route = sub.split('?').next().unwrap_or(sub);
|
|
let query = sub.split_once('?').map(|(_, q)| q).unwrap_or("");
|
|
|
|
match route {
|
|
"list" => {
|
|
// Layer 1: OS process list (simplified — just browser + webprocess).
|
|
let processes = crate::process_info::process_info_get_all();
|
|
respond_json(request, &serde_json::to_string(&processes).unwrap_or_default());
|
|
}
|
|
"tabs" => {
|
|
// Layer 2: per-tab list with probe data.
|
|
let tabs = crate::tab_manager::tab_manager_get_tabs();
|
|
let tab_infos: Vec<serde_json::Value> = tabs.iter().map(|t| {
|
|
serde_json::json!({
|
|
"id": t.id,
|
|
"title": t.title,
|
|
"url": t.url,
|
|
"is_internal": t.url.starts_with("sovereign://") || t.url.starts_with("about:"),
|
|
})
|
|
}).collect();
|
|
respond_json(request, &serde_json::to_string(&tab_infos).unwrap_or_default());
|
|
}
|
|
"probe-report" => {
|
|
// Receive perf probe report from injected JS.
|
|
// ?tab_index=N&body=<urlencoded JSON>
|
|
let tab_index = extract_query_param(query, "tab_index")
|
|
.and_then(|s| s.parse::<i32>().ok())
|
|
.unwrap_or(-1);
|
|
let body = extract_query_param(query, "body").unwrap_or_default();
|
|
if let Ok(report) = serde_json::from_str::<serde_json::Value>(&body) {
|
|
crate::perf_probe::perf_probe_record_report(tab_index, &report);
|
|
}
|
|
// Return empty JSON (204-like).
|
|
respond_json(request, "{}");
|
|
}
|
|
"tab_probe" => {
|
|
// Drill-down for a single tab.
|
|
let index = extract_query_param(query, "index")
|
|
.and_then(|s| s.parse::<usize>().ok())
|
|
.unwrap_or(0);
|
|
let report = crate::perf_probe::perf_probe_get_report(index);
|
|
respond_json(request, &serde_json::to_string(&report).unwrap_or_default());
|
|
}
|
|
"tab_action" => {
|
|
// ?index=N&action=reload|suspend|close
|
|
let _index = extract_query_param(query, "index")
|
|
.and_then(|s| s.parse::<usize>().ok())
|
|
.unwrap_or(0);
|
|
let action = extract_query_param(query, "action").unwrap_or_default();
|
|
match action.as_str() {
|
|
"reload" => {
|
|
glib::idle_add_once(move || {
|
|
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
|
|
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
|
|
wv.reload();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
"close" => {
|
|
glib::idle_add_once(move || {
|
|
crate::tab_manager::tab_manager_close_active();
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
respond_json(request, &serde_json::json!({"status": "ok"}).to_string());
|
|
}
|
|
_ => respond_error_json(request, 404, "Unknown processes route"),
|
|
}
|
|
}
|
|
|
|
/// Handle sovereign://bookmarks/* REST API.
|
|
/// Mirrors the C version routes: list, add, delete, createdir, deletedir,
|
|
/// move, rename, renamedir. Returns JSON (or redirects back to bookmarks).
|
|
fn handle_bookmarks_api(request: &URISchemeRequest, sub: &str) {
|
|
let route = sub.split('?').next().unwrap_or(sub);
|
|
let query = sub.split_once('?').map(|(_, q)| q).unwrap_or("");
|
|
|
|
match route {
|
|
"list" => {
|
|
let have_signer = !G_BRIDGE.lock().unwrap().readonly;
|
|
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
|
|
let tree = build_bookmark_tree(&bookmarks);
|
|
let json = serde_json::json!({
|
|
"have_signer": have_signer,
|
|
"tree": tree,
|
|
});
|
|
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
|
|
}
|
|
"add" => {
|
|
let dir = extract_query_param(query, "dir").unwrap_or_default();
|
|
let url = extract_query_param(query, "url").unwrap_or_default();
|
|
let title = extract_query_param(query, "title").unwrap_or_default();
|
|
match crate::bookmarks::bookmarks_add(&title, &url, &dir) {
|
|
Ok(_) => respond_redirect(request, "sovereign://bookmarks"),
|
|
Err(e) => respond_error_json(request, 500, &e.to_string()),
|
|
}
|
|
}
|
|
"delete" => {
|
|
let dir = extract_query_param(query, "dir").unwrap_or_default();
|
|
let url = extract_query_param(query, "url").unwrap_or_default();
|
|
// Find the bookmark id by url+folder and delete.
|
|
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
|
|
let found = bookmarks.iter().find(|b| {
|
|
b["url"].as_str() == Some(url.as_str())
|
|
&& b["folder"].as_str().unwrap_or("") == dir.as_str()
|
|
}).and_then(|b| b["id"].as_i64());
|
|
match found {
|
|
Some(id) => match crate::bookmarks::bookmarks_delete(id) {
|
|
Ok(_) => respond_redirect(request, "sovereign://bookmarks"),
|
|
Err(e) => respond_error_json(request, 500, &e.to_string()),
|
|
},
|
|
None => respond_error_json(request, 404, "Bookmark not found"),
|
|
}
|
|
}
|
|
"createdir" => {
|
|
// Folders are implicit (folder path on bookmarks). Creating a dir
|
|
// is a no-op — the folder appears when a bookmark is added to it.
|
|
respond_redirect(request, "sovereign://bookmarks");
|
|
}
|
|
"deletedir" => {
|
|
let dir = extract_query_param(query, "dir").unwrap_or_default();
|
|
// Delete all bookmarks under this folder path.
|
|
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
|
|
let ids: Vec<i64> = bookmarks.iter()
|
|
.filter(|b| b["folder"].as_str().unwrap_or("").starts_with(&format!("{}/", dir))
|
|
|| b["folder"].as_str().unwrap_or("") == dir.as_str())
|
|
.filter_map(|b| b["id"].as_i64())
|
|
.collect();
|
|
for id in ids {
|
|
let _ = crate::bookmarks::bookmarks_delete(id);
|
|
}
|
|
respond_redirect(request, "sovereign://bookmarks");
|
|
}
|
|
"rename" => {
|
|
let dir = extract_query_param(query, "dir").unwrap_or_default();
|
|
let url = extract_query_param(query, "url").unwrap_or_default();
|
|
let title = extract_query_param(query, "title").unwrap_or_default();
|
|
// Update title via direct DB access.
|
|
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
|
|
let found = bookmarks.iter().find(|b| {
|
|
b["url"].as_str() == Some(url.as_str())
|
|
&& b["folder"].as_str().unwrap_or("") == dir.as_str()
|
|
}).and_then(|b| b["id"].as_i64());
|
|
if let Some(id) = found {
|
|
let _ = db::db_update_bookmark_title(id, &title);
|
|
}
|
|
respond_redirect(request, "sovereign://bookmarks");
|
|
}
|
|
"renamedir" => {
|
|
let old = extract_query_param(query, "old").unwrap_or_default();
|
|
let new = extract_query_param(query, "new").unwrap_or_default();
|
|
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
|
|
for b in &bookmarks {
|
|
let folder = b["folder"].as_str().unwrap_or("").to_string();
|
|
if let Some(id) = b["id"].as_i64() {
|
|
if folder == old || folder.starts_with(&format!("{}/", old)) {
|
|
let new_folder = if folder == old {
|
|
new.clone()
|
|
} else {
|
|
format!("{}{}", new, &folder[old.len()..])
|
|
};
|
|
let _ = db::db_update_bookmark_folder(id, &new_folder);
|
|
}
|
|
}
|
|
}
|
|
respond_redirect(request, "sovereign://bookmarks");
|
|
}
|
|
"move" => {
|
|
let from = extract_query_param(query, "from").unwrap_or_default();
|
|
let to = extract_query_param(query, "to").unwrap_or_default();
|
|
let url = extract_query_param(query, "url").unwrap_or_default();
|
|
let bookmarks = crate::bookmarks::bookmarks_get_all().unwrap_or_default();
|
|
let found = bookmarks.iter().find(|b| {
|
|
b["url"].as_str() == Some(url.as_str())
|
|
&& b["folder"].as_str().unwrap_or("") == from.as_str()
|
|
}).and_then(|b| b["id"].as_i64());
|
|
if let Some(id) = found {
|
|
let _ = db::db_update_bookmark_folder(id, &to);
|
|
}
|
|
respond_redirect(request, "sovereign://bookmarks");
|
|
}
|
|
_ => respond_error_json(request, 404, "Unknown bookmarks route"),
|
|
}
|
|
}
|
|
|
|
/// Build the bookmark tree JSON from a flat bookmark list.
|
|
/// Shape: {"name": "", "path": "", "bookmarks": [], "children": [...]}
|
|
fn build_bookmark_tree(bookmarks: &[serde_json::Value]) -> serde_json::Value {
|
|
// Collect distinct folder paths.
|
|
let mut folders: Vec<String> = Vec::new();
|
|
for b in bookmarks {
|
|
let f = b["folder"].as_str().unwrap_or("").to_string();
|
|
if !f.is_empty() && !folders.contains(&f) {
|
|
folders.push(f);
|
|
}
|
|
}
|
|
|
|
// Build a tree of folder path components.
|
|
fn node(name: &str, path: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"name": name,
|
|
"path": path,
|
|
"count": 0,
|
|
"bookmarks": [],
|
|
"children": [],
|
|
})
|
|
}
|
|
|
|
// Root node.
|
|
let mut root = node("", "");
|
|
let root_bookmarks: Vec<serde_json::Value> = bookmarks.iter()
|
|
.filter(|b| b["folder"].as_str().unwrap_or("").is_empty())
|
|
.cloned()
|
|
.collect();
|
|
root["bookmarks"] = serde_json::Value::Array(root_bookmarks);
|
|
|
|
// Build folder tree.
|
|
let mut sorted_folders = folders.clone();
|
|
sorted_folders.sort();
|
|
for f in &sorted_folders {
|
|
let parts: Vec<&str> = f.split('/').collect();
|
|
let mut current = &mut root;
|
|
let mut path_acc = String::new();
|
|
for (i, part) in parts.iter().enumerate() {
|
|
if i > 0 { path_acc.push('/'); }
|
|
path_acc.push_str(part);
|
|
let children = current["children"].as_array_mut().unwrap();
|
|
let idx = children.iter().position(|c| c["name"].as_str() == Some(*part));
|
|
let child_idx = match idx {
|
|
Some(i) => i,
|
|
None => {
|
|
children.push(node(part, &path_acc));
|
|
children.len() - 1
|
|
}
|
|
};
|
|
current = &mut children[child_idx];
|
|
}
|
|
// Attach bookmarks in this folder.
|
|
let fms: Vec<serde_json::Value> = bookmarks.iter()
|
|
.filter(|b| b["folder"].as_str().unwrap_or("") == f.as_str())
|
|
.cloned()
|
|
.collect();
|
|
let fms_len = fms.len();
|
|
current["bookmarks"] = serde_json::Value::Array(fms);
|
|
current["count"] = serde_json::json!(fms_len);
|
|
}
|
|
|
|
root
|
|
}
|
|
|
|
/// Redirect a request to another sovereign:// URL.
|
|
fn respond_redirect(request: &URISchemeRequest, target: &str) {
|
|
let html = format!(
|
|
"<!DOCTYPE html><html><head>\
|
|
<meta http-equiv='refresh' content='0; url={}'>\
|
|
</head><body>Redirecting…</body></html>",
|
|
target
|
|
);
|
|
respond_html(request, &html);
|
|
}
|
|
|
|
/// Handle sovereign://profile/data — return the user's Nostr profile data.
|
|
fn handle_profile_data_json(request: &URISchemeRequest) {
|
|
let state = G_BRIDGE.lock().unwrap();
|
|
let pubkey_hex = state.pubkey_hex.clone();
|
|
let readonly = state.readonly;
|
|
let method = crate::menu::app_get_method().as_str().to_string();
|
|
drop(state);
|
|
|
|
// Query kind 0 (profile) events from the DB.
|
|
let events = db::db_query_events(&[0], None, None, None, Some(20)).unwrap_or_default();
|
|
let kind0 = events.first();
|
|
let kind0_present = kind0.is_some();
|
|
|
|
let kind0_content: serde_json::Value = kind0
|
|
.and_then(|e| e.get("content").and_then(|c| c.as_str()))
|
|
.and_then(|c| serde_json::from_str(c).ok())
|
|
.unwrap_or_else(|| serde_json::json!({}));
|
|
|
|
// Query kind 3 (contacts) events.
|
|
let contacts_events = db::db_query_events(&[3], None, None, None, Some(20)).unwrap_or_default();
|
|
let kind3 = contacts_events.first();
|
|
let kind3_present = kind3.is_some();
|
|
let contact_count = kind3
|
|
.and_then(|e| e.get("tags"))
|
|
.and_then(|t| t.as_array())
|
|
.map(|t| t.len())
|
|
.unwrap_or(0);
|
|
|
|
// Kind 10002 relay list.
|
|
let relay_events = db::db_query_events(&[10002], None, None, None, Some(20)).unwrap_or_default();
|
|
let kind10002_count = relay_events.len();
|
|
|
|
let json = serde_json::json!({
|
|
"pubkey": pubkey_hex,
|
|
"pubkey_short": if pubkey_hex.len() >= 12 {
|
|
format!("{}…{}", &pubkey_hex[..8], &pubkey_hex[pubkey_hex.len()-4..])
|
|
} else { pubkey_hex.clone() },
|
|
"npub": nostr_npub_of_hex(&pubkey_hex),
|
|
"method": method,
|
|
"signing": if readonly { "read-only" } else { "signing" },
|
|
"picture": kind0_content.get("picture").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
|
"name": kind0_content.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
|
"display_name": kind0_content.get("display_name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
|
"about": kind0_content.get("about").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
|
"website": kind0_content.get("website").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
|
"lud16": kind0_content.get("lud16").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
|
"nip05": kind0_content.get("nip05").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
|
"kind0_present": kind0_present,
|
|
"kind0_created": kind0.and_then(|e| e.get("created_at")).map(|c| c.to_string()).unwrap_or_default(),
|
|
"kind0_count": events.len(),
|
|
"kind3_present": kind3_present,
|
|
"contact_count": contact_count,
|
|
"kind3_created": kind3.and_then(|e| e.get("created_at")).map(|c| c.to_string()).unwrap_or_default(),
|
|
"kind3_count": contacts_events.len(),
|
|
"kind10002_count": kind10002_count,
|
|
"qr_url": if pubkey_hex.is_empty() { "" } else {
|
|
"sovereign://qr?text=npub-placeholder&box_size=6&border=4"
|
|
},
|
|
});
|
|
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
|
|
}
|
|
|
|
/// Convert a hex pubkey to an npub string (best-effort).
|
|
fn nostr_npub_of_hex(hex_str: &str) -> String {
|
|
use nostr_core::util::bech32::npub_encode;
|
|
let bytes = match hex::decode(hex_str) {
|
|
Ok(b) if b.len() == 32 => b,
|
|
_ => return String::new(),
|
|
};
|
|
let mut arr = [0u8; 32];
|
|
arr.copy_from_slice(&bytes);
|
|
let pk = nostr_core::types::PublicKey::from_bytes(arr);
|
|
npub_encode(&pk).unwrap_or_default()
|
|
}
|
|
|
|
/// Handle sovereign://qr?text=... — render a QR code as PNG.
|
|
fn handle_qr(request: &URISchemeRequest, full_path: &str) {
|
|
let query = full_path.split_once('?').map(|(_, q)| q).unwrap_or("");
|
|
let text = extract_query_param(query, "text").unwrap_or_default();
|
|
if text.is_empty() {
|
|
respond_error_json(request, 400, "Missing text parameter");
|
|
return;
|
|
}
|
|
match crate::qr::qr_generate_png(&text) {
|
|
Ok(png) => respond_bytes_raw(request, png, "image/png"),
|
|
Err(e) => respond_error_json(request, 500, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Handle sovereign://settings/set — toggle (feature=X) or set (key=X&value=Y).
|
|
/// Mirrors the C version (nostr_bridge.c:1036).
|
|
fn handle_settings_set(request: &URISchemeRequest, query: &str) {
|
|
// Toggle path: ?feature=X
|
|
if let Some(feature) = extract_query_param(query, "feature") {
|
|
let (new_state, reload) = match feature.as_str() {
|
|
"restore_session" => {
|
|
let mut s = settings::settings_get();
|
|
s.session_restore = !s.session_restore;
|
|
settings::settings_update(&serde_json::json!({"session_restore": s.session_restore}));
|
|
settings::settings_save();
|
|
(s.session_restore, false)
|
|
}
|
|
"show_tab_close_buttons" => {
|
|
let mut s = settings::settings_get();
|
|
s.show_tab_close_buttons = !s.show_tab_close_buttons;
|
|
settings::settings_update(&serde_json::json!({"show_tab_close_buttons": s.show_tab_close_buttons}));
|
|
settings::settings_save();
|
|
(s.show_tab_close_buttons, false)
|
|
}
|
|
"middle_click_close" => {
|
|
let mut s = settings::settings_get();
|
|
s.middle_click_close = !s.middle_click_close;
|
|
settings::settings_update(&serde_json::json!({"middle_click_close": s.middle_click_close}));
|
|
settings::settings_save();
|
|
(s.middle_click_close, false)
|
|
}
|
|
"ctrl_tab_switch" => {
|
|
let mut s = settings::settings_get();
|
|
s.ctrl_tab_switch = !s.ctrl_tab_switch;
|
|
settings::settings_update(&serde_json::json!({"ctrl_tab_switch": s.ctrl_tab_switch}));
|
|
settings::settings_save();
|
|
(s.ctrl_tab_switch, false)
|
|
}
|
|
"tab_drag_reorder" => {
|
|
let mut s = settings::settings_get();
|
|
s.tab_drag_reorder = !s.tab_drag_reorder;
|
|
settings::settings_update(&serde_json::json!({"tab_drag_reorder": s.tab_drag_reorder}));
|
|
settings::settings_save();
|
|
(s.tab_drag_reorder, false)
|
|
}
|
|
"theme_dark" => {
|
|
let mut s = settings::settings_get();
|
|
s.theme_dark = !s.theme_dark;
|
|
settings::settings_update(&serde_json::json!({"theme_dark": s.theme_dark}));
|
|
settings::settings_save();
|
|
(s.theme_dark, false)
|
|
}
|
|
"agent_server_enabled" => {
|
|
// Toggle agent server enable state. Stored in DB directly.
|
|
let current = db::db_kv_get("agent_server_enabled").ok().flatten().unwrap_or_else(|| "true".to_string());
|
|
let new = if current == "true" { "false" } else { "true" };
|
|
let _ = db::db_kv_set("agent_server_enabled", new);
|
|
(new == "true", true)
|
|
}
|
|
_ => {
|
|
respond_error_json(request, 400, "Unknown feature");
|
|
return;
|
|
}
|
|
};
|
|
println!("[settings] {}: {}", feature, if new_state { "ON" } else { "OFF" });
|
|
let mut result = serde_json::json!({ "key": feature, "value": new_state });
|
|
if reload { result["reload"] = serde_json::json!(true); }
|
|
respond_json(request, &serde_json::to_string(&result).unwrap_or_default());
|
|
return;
|
|
}
|
|
|
|
// Key/value path: ?key=X&value=Y
|
|
let key = match extract_query_param(query, "key") {
|
|
Some(k) => k,
|
|
None => {
|
|
respond_error_json(request, 400, "Missing key parameter");
|
|
return;
|
|
}
|
|
};
|
|
let value = extract_query_param(query, "value").unwrap_or_default();
|
|
|
|
// Handle shortcut pseudo-keys.
|
|
if key == "shortcut.reset_all" {
|
|
crate::shortcuts::shortcuts_init();
|
|
crate::shortcuts::shortcuts_save();
|
|
println!("[settings] shortcut.reset_all");
|
|
respond_json(request, &serde_json::json!({"key": "shortcut.reset_all", "value": "defaults"}).to_string());
|
|
return;
|
|
}
|
|
if key == "shortcut.reset" {
|
|
// Reset a single shortcut by action.
|
|
if let Some(action) = extract_query_param(query, "action") {
|
|
crate::shortcuts::shortcuts_init();
|
|
crate::shortcuts::shortcuts_save();
|
|
println!("[settings] shortcut.reset {}", action);
|
|
respond_json(request, &serde_json::json!({"key": "shortcut.reset", "value": &action}).to_string());
|
|
return;
|
|
}
|
|
}
|
|
if key.starts_with("shortcut.") {
|
|
// Set a shortcut: key="shortcut.new_tab", value="<Control>t"
|
|
// Our Shortcut model uses modifiers+key. For now, reinitialize.
|
|
crate::shortcuts::shortcuts_init();
|
|
crate::shortcuts::shortcuts_save();
|
|
println!("[settings] shortcut set: {} = {}", key, value);
|
|
respond_json(request, &serde_json::json!({"key": key, "value": value}).to_string());
|
|
return;
|
|
}
|
|
|
|
let reload = match key.as_str() {
|
|
"new_tab_url" => {
|
|
settings::settings_update(&serde_json::json!({ "new_tab_url": value }));
|
|
settings::settings_save();
|
|
false
|
|
}
|
|
"tab_bar_position" => {
|
|
let pos = match value.as_str() {
|
|
"top" => 0,
|
|
"bottom" => 1,
|
|
"left" => 2,
|
|
"right" => 3,
|
|
_ => { respond_error_json(request, 400, "Invalid position value"); return; }
|
|
};
|
|
settings::settings_update(&serde_json::json!({ "tab_bar_position": pos }));
|
|
settings::settings_save();
|
|
false
|
|
}
|
|
"max_tabs" => {
|
|
let v: i32 = value.parse().unwrap_or(1).max(1);
|
|
settings::settings_update(&serde_json::json!({ "max_tabs": v }));
|
|
settings::settings_save();
|
|
false
|
|
}
|
|
"agent_server_port" => {
|
|
let v: i32 = value.parse().unwrap_or(17777);
|
|
if v < 1 || v > 65535 { respond_error_json(request, 400, "Port out of range (1-65535)"); return; }
|
|
settings::settings_update(&serde_json::json!({ "agent_port": v }));
|
|
settings::settings_save();
|
|
true
|
|
}
|
|
"agent_allowed_origins" => {
|
|
let _ = db::db_kv_set("agent_allowed_origins", &value);
|
|
false
|
|
}
|
|
"agent_login_timeout_ms" => {
|
|
let v: i64 = value.parse().unwrap_or(0).max(0);
|
|
let _ = db::db_kv_set("agent_login_timeout_ms", &v.to_string());
|
|
false
|
|
}
|
|
"bootstrap_relays" => {
|
|
settings::settings_update(&serde_json::json!({ "bootstrap_relays": value }));
|
|
settings::settings_save();
|
|
false
|
|
}
|
|
"search_engine" => {
|
|
let valid = matches!(value.as_str(), "duckduckgo" | "google" | "brave");
|
|
if !valid { respond_error_json(request, 400, "Unknown search engine"); return; }
|
|
settings::settings_update(&serde_json::json!({ "search_engine": value }));
|
|
settings::settings_save();
|
|
false
|
|
}
|
|
_ => { respond_error_json(request, 400, "Unknown key"); return; }
|
|
};
|
|
|
|
let mut result = serde_json::json!({ "key": key, "value": value });
|
|
if reload { result["reload"] = serde_json::json!(true); }
|
|
respond_json(request, &serde_json::to_string(&result).unwrap_or_default());
|
|
}
|
|
|
|
/// Handle sovereign://settings/config — return all settings as JSON.
|
|
/// Mirrors the C version (nostr_bridge.c:2940).
|
|
fn handle_settings_config_json(request: &URISchemeRequest) {
|
|
let s = settings::settings_get();
|
|
|
|
let pos_str = match s.tab_bar_position {
|
|
0 => "top",
|
|
1 => "bottom",
|
|
2 => "left",
|
|
3 => "right",
|
|
_ => "top",
|
|
};
|
|
|
|
// Search engine list.
|
|
let search_engines = serde_json::json!([
|
|
{"id": "duckduckgo", "name": "DuckDuckGo"},
|
|
{"id": "google", "name": "Google"},
|
|
{"id": "brave", "name": "Brave"},
|
|
]);
|
|
|
|
// Keyboard shortcuts.
|
|
let all_shortcuts = crate::shortcuts::shortcuts_get_all();
|
|
let shortcuts: Vec<serde_json::Value> = all_shortcuts.iter().map(|sc| {
|
|
let accel = format!("<{}>{}", sc.modifiers, sc.key);
|
|
serde_json::json!({
|
|
"id": sc.action,
|
|
"label": sc.action,
|
|
"accel": accel,
|
|
})
|
|
}).collect();
|
|
|
|
let json = serde_json::json!({
|
|
"theme_dark": s.theme_dark,
|
|
"restore_session": s.session_restore,
|
|
"new_tab_url": s.new_tab_url,
|
|
"tab_bar_position": pos_str,
|
|
"show_tab_close_buttons": s.show_tab_close_buttons,
|
|
"middle_click_close": s.middle_click_close,
|
|
"ctrl_tab_switch": s.ctrl_tab_switch,
|
|
"tab_drag_reorder": s.tab_drag_reorder,
|
|
"max_tabs": s.max_tabs,
|
|
"agent_server_enabled": true,
|
|
"agent_server_port": s.agent_port,
|
|
"agent_allowed_origins": "",
|
|
"agent_login_timeout_ms": 30000,
|
|
"bootstrap_relays": s.bootstrap_relays,
|
|
"search_engine": s.search_engine,
|
|
"dev_extras": true,
|
|
"file_access": true,
|
|
"universal_access": true,
|
|
"search_engines": search_engines,
|
|
"shortcuts": shortcuts,
|
|
});
|
|
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
|
|
}
|
|
|
|
fn handle_nostr_api(request: &URISchemeRequest, method: &str) {
|
|
// The method may have a query string (e.g. "signEvent?body=...").
|
|
// Split it off.
|
|
let (method_name, query) = method.split_once('?').unwrap_or((method, ""));
|
|
let body_param = extract_query_param(query, "body");
|
|
|
|
match method_name {
|
|
"getPublicKey" => {
|
|
let state = G_BRIDGE.lock().unwrap();
|
|
let json = serde_json::json!({ "pubkey": state.pubkey_hex });
|
|
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
|
|
}
|
|
"signEvent" => {
|
|
let event_json = match body_param {
|
|
Some(b) => b,
|
|
None => match read_request_body(request) {
|
|
Ok(b) => b,
|
|
Err(_) => {
|
|
respond_error_json(request, 400, "Invalid request body");
|
|
return;
|
|
}
|
|
},
|
|
};
|
|
let state = G_BRIDGE.lock().unwrap();
|
|
if state.readonly {
|
|
respond_error_json(request, 403, "Read-only mode");
|
|
return;
|
|
}
|
|
if let Some(ref signer) = state.signer {
|
|
match serde_json::from_str::<nostr_core::types::Event>(&event_json) {
|
|
Ok(event) => {
|
|
match signer.sign_event(&event) {
|
|
Ok(signed) => {
|
|
let result = serde_json::to_string(&signed).unwrap_or_default();
|
|
respond_json(request, &result);
|
|
}
|
|
Err(e) => respond_error_json(request, 500, &e.to_string()),
|
|
}
|
|
}
|
|
Err(e) => respond_error_json(request, 400, &e.to_string()),
|
|
}
|
|
} else {
|
|
respond_error_json(request, 500, "No signer available");
|
|
}
|
|
}
|
|
"getRelays" => {
|
|
let relays = settings::settings_get_bootstrap_relays();
|
|
let json = serde_json::json!({ "relays": relays });
|
|
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
|
|
}
|
|
"nip04Encrypt" | "nip04Decrypt" | "nip44Encrypt" | "nip44Decrypt" => {
|
|
let body_json = match &body_param {
|
|
Some(b) => b.clone(),
|
|
None => match read_request_body(request) {
|
|
Ok(b) => b,
|
|
Err(_) => {
|
|
respond_error_json(request, 400, "Invalid request body");
|
|
return;
|
|
}
|
|
},
|
|
};
|
|
let params: serde_json::Value = match serde_json::from_str(&body_json) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
respond_error_json(request, 400, &format!("Invalid JSON: {}", e));
|
|
return;
|
|
}
|
|
};
|
|
let peer_pubkey_hex = match params.get("peer_pubkey").and_then(|v| v.as_str()) {
|
|
Some(s) => s.to_string(),
|
|
None => {
|
|
respond_error_json(request, 400, "Missing peer_pubkey");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let state = G_BRIDGE.lock().unwrap();
|
|
if state.readonly {
|
|
respond_error_json(request, 403, "Read-only mode");
|
|
return;
|
|
}
|
|
let signer = match &state.signer {
|
|
Some(s) => s.clone(),
|
|
None => {
|
|
respond_error_json(request, 500, "No signer available");
|
|
return;
|
|
}
|
|
};
|
|
drop(state);
|
|
|
|
// Parse the peer pubkey.
|
|
let peer_pk = match parse_pubkey_hex(&peer_pubkey_hex) {
|
|
Some(pk) => pk,
|
|
None => {
|
|
respond_error_json(request, 400, "Invalid peer_pubkey");
|
|
return;
|
|
}
|
|
};
|
|
|
|
match method_name {
|
|
"nip04Encrypt" => {
|
|
let plaintext = params.get("plaintext").and_then(|v| v.as_str()).unwrap_or("");
|
|
match signer.nip04_encrypt(&peer_pk, plaintext) {
|
|
Ok(ciphertext) => {
|
|
let json = serde_json::json!({ "result": ciphertext });
|
|
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
|
|
}
|
|
Err(e) => respond_error_json(request, 500, &e.to_string()),
|
|
}
|
|
}
|
|
"nip04Decrypt" => {
|
|
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str()).unwrap_or("");
|
|
match signer.nip04_decrypt(&peer_pk, ciphertext) {
|
|
Ok(plaintext) => {
|
|
let json = serde_json::json!({ "result": plaintext });
|
|
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
|
|
}
|
|
Err(e) => respond_error_json(request, 500, &e.to_string()),
|
|
}
|
|
}
|
|
"nip44Encrypt" => {
|
|
let plaintext = params.get("plaintext").and_then(|v| v.as_str()).unwrap_or("");
|
|
match signer.nip44_encrypt(&peer_pk, plaintext) {
|
|
Ok(ciphertext) => {
|
|
let json = serde_json::json!({ "result": ciphertext });
|
|
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
|
|
}
|
|
Err(e) => respond_error_json(request, 500, &e.to_string()),
|
|
}
|
|
}
|
|
"nip44Decrypt" => {
|
|
let ciphertext_bytes: Vec<u8> = params.get("ciphertext").map(|v| {
|
|
if let Some(s) = v.as_str() {
|
|
s.as_bytes().to_vec()
|
|
} else if let Some(arr) = v.as_array() {
|
|
arr.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect()
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}).unwrap_or_default();
|
|
match signer.nip44_decrypt(&peer_pk, &ciphertext_bytes) {
|
|
Ok(plaintext) => {
|
|
let json = serde_json::json!({ "result": plaintext });
|
|
respond_json(request, &serde_json::to_string(&json).unwrap_or_default());
|
|
}
|
|
Err(e) => respond_error_json(request, 500, &e.to_string()),
|
|
}
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
_ => respond_error_json(request, 404, "Unknown method"),
|
|
}
|
|
}
|
|
|
|
/// Extract a query parameter value from a query string (e.g. "body=...&foo=bar").
|
|
fn extract_query_param(query: &str, key: &str) -> Option<String> {
|
|
for pair in query.split('&') {
|
|
if let Some((k, v)) = pair.split_once('=') {
|
|
if k == key {
|
|
// URL-decode the value.
|
|
return Some(url_decode(v));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Simple URL percent-decoding.
|
|
fn url_decode(s: &str) -> String {
|
|
let mut result = String::with_capacity(s.len());
|
|
let mut chars = s.chars().peekable();
|
|
while let Some(c) = chars.next() {
|
|
if c == '%' {
|
|
let h1 = chars.next();
|
|
let h2 = chars.next();
|
|
if let (Some(a), Some(b)) = (h1, h2) {
|
|
if let Ok(byte) = u8::from_str_radix(&format!("{}{}", a, b), 16) {
|
|
result.push(byte as char);
|
|
continue;
|
|
}
|
|
}
|
|
result.push(c);
|
|
} else if c == '+' {
|
|
result.push(' ');
|
|
} else {
|
|
result.push(c);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Parse a hex pubkey string into a PublicKey.
|
|
fn parse_pubkey_hex(hex_str: &str) -> Option<nostr_core::types::PublicKey> {
|
|
let bytes = hex::decode(hex_str).ok()?;
|
|
if bytes.len() != 32 {
|
|
return None;
|
|
}
|
|
let arr: [u8; 32] = bytes.try_into().ok()?;
|
|
Some(nostr_core::types::PublicKey::from_bytes(arr))
|
|
}
|
|
|
|
fn respond_json(request: &URISchemeRequest, json: &str) {
|
|
let body = json.to_string().into_bytes();
|
|
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
|
|
request.finish(&stream, body.len() as i64, Some("application/json"));
|
|
}
|
|
|
|
fn respond_bytes(request: &URISchemeRequest, content: &str, mime: &str) {
|
|
let body = content.as_bytes().to_vec();
|
|
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
|
|
request.finish(&stream, body.len() as i64, Some(mime));
|
|
}
|
|
|
|
fn respond_bytes_raw(request: &URISchemeRequest, body: Vec<u8>, mime: &str) {
|
|
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
|
|
request.finish(&stream, body.len() as i64, Some(mime));
|
|
}
|
|
|
|
fn respond_html(request: &URISchemeRequest, html: &str) {
|
|
let body = html.to_string().into_bytes();
|
|
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
|
|
request.finish(&stream, body.len() as i64, Some("text/html"));
|
|
}
|
|
|
|
fn respond_error_json(request: &URISchemeRequest, _code: i32, message: &str) {
|
|
let json = serde_json::json!({ "error": message });
|
|
let body = serde_json::to_string(&json).unwrap_or_default().into_bytes();
|
|
let stream = MemoryInputStream::from_bytes(&Bytes::from_owned(body.clone()));
|
|
request.finish(&stream, body.len() as i64, Some("application/json"));
|
|
}
|
|
|
|
fn read_request_body(request: &URISchemeRequest) -> Result<String, ()> {
|
|
if let Some(body_stream) = request.http_body() {
|
|
let mut buf = vec![0u8; 65536];
|
|
let cancellable: Option<&gio::Cancellable> = None;
|
|
if body_stream.read_all(&mut buf, cancellable).is_ok() {
|
|
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
|
buf.truncate(len);
|
|
return String::from_utf8(buf).map_err(|_| ());
|
|
}
|
|
}
|
|
Err(())
|
|
}
|