v0.0.16 - MCP server improvements: coordinate-based clicking via GDK events, bounding box ref storage, stale ref fallback, click_at tool, GDK keyboard events for fill/type, NIP-07 bridge fix (sync XHR), page aesthetics matching client, inspector shortcut Ctrl+Shift+I, Night/Day theme toggle

This commit is contained in:
Laan Tungir
2026-07-12 18:02:28 -04:00
parent f01a345b7c
commit 775bf27ab0
17 changed files with 1148 additions and 165 deletions

View File

@@ -1 +1 @@
0.0.15
0.0.16

78
plans/mcp-improvements.md Normal file
View File

@@ -0,0 +1,78 @@
# MCP Server Improvements Plan
## Problem Assessment
The sovereign_browser MCP server uses JavaScript-level interaction (JS `element.click()`,
`dispatchEvent`, `document.querySelector`) which has critical limitations:
1. **CSS selectors with special chars** (e.g. `[60vh]`) break in JSON escaping
2. **JS `.click()` doesn't trigger SPA framework handlers** (React synthetic events, Radix UI pointer events)
3. **Stale refs fail completely** instead of re-resolving
4. **No coordinate-based interaction** — everything depends on CSS selectors working
## agent-browser's Approach (CDP)
agent-browser uses Chrome DevTools Protocol:
- `Accessibility.getFullAXTree` → native AX tree with `backend_node_id` per node
- `DOM.getBoxModel(backend_node_id)` → pixel coordinates of element
- `Input.dispatchMouseEvent(x, y)` → real browser-level mouse events
- Stale `backend_node_id` → re-query AX tree by role+name
## Proposed Improvements for sovereign_browser
### 1. Coordinate-based clicking (highest impact, easiest)
Instead of JS `.click()`, get the element's bounding box and synthesize a GDK event:
```c
// In agent_tools.c, the click tool:
// 1. JS: getBoundingClientRect() → {x, y, width, height}
// 2. C: gdk_event_new(GDK_BUTTON_PRESS) at center coordinates
// 3. C: gtk_main_do_event() to dispatch to WebKit
```
This triggers real WebKit hit-testing and full event propagation, matching how
agent-browser's `Input.dispatchMouseEvent` works.
### 2. Fix ref storage — store bounding box, not just CSS selector
At snapshot time, store each ref's:
- `backend_node_id` (WebKit accessible's unique ID)
- `role` + `name` (for re-resolution if stale)
- `bounding_box` (x, y, width, height — for coordinate-based fallback)
- `selector` (CSS selector — as secondary fallback)
### 3. Stale ref fallback — re-resolve by role+name
If the CSS selector fails, re-query the accessibility tree by role+name
(matching agent-browser's `find_node_id_by_role_name` approach).
### 4. Use WebKit's native accessibility tree for snapshots
Instead of walking the DOM in JS, use WebKit's ATK accessibility:
```c
WebKitWebView *wv = ...;
AtkObject *root = webkit_web_view_get_accessible(wv);
// Walk the ATK tree — stable node IDs, no CSS selector issues
```
### 5. Add `click_at(x, y)` tool
A simple coordinate-based click tool that synthesizes GDK events.
Useful when you know the coordinates from a screenshot.
### 6. Fix `fill`/`type` to use GDK keyboard events
Instead of JS `element.value = ...`, use:
- JS to focus the element
- GDK key events to type character by character
- This triggers proper input validation and React onChange handlers
## Priority
1. **Coordinate-based clicking** — fixes the immediate "can't click buttons" problem
2. **Fix ref storage with bounding box** — enables coordinate fallback
3. **Stale ref fallback** — improves reliability
4. **Native AX tree snapshots** — better stability, less JS dependency
5. **GDK keyboard events for typing** — better SPA compatibility
6. **`click_at` tool** — convenience for screenshot-based interaction

View File

@@ -225,9 +225,13 @@ static mcp_tool_def_t tool_defs[] = {
/* Interaction tools */
{"click",
"Click an element by ref or CSS selector.",
"Click an element by ref or CSS selector. Uses coordinate-based GDK event synthesis (real WebKit hit-testing) for SPA framework compatibility, with JS .click() fallback.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"click_at",
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation.",
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"]}"},
{"fill",
"Clear an input and fill it with a value.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},

View File

@@ -337,6 +337,7 @@ const char *AGENT_SNAPSHOT_JS =
" refCount++;\n"
" var ref = 'e' + refCount;\n"
" var selector = getSelector(el);\n"
" var rect = el.getBoundingClientRect();\n"
" refMap[ref] = {\n"
" role: role,\n"
" name: name,\n"
@@ -344,7 +345,13 @@ const char *AGENT_SNAPSHOT_JS =
" tag: el.tagName.toLowerCase(),\n"
" href: el.href || '',\n"
" type: el.type || '',\n"
" value: el.value || ''\n"
" value: el.value || '',\n"
" bbox: {\n"
" x: rect.x,\n"
" y: rect.y,\n"
" width: rect.width,\n"
" height: rect.height\n"
" }\n"
" };\n"
" line += ' [ref=' + ref + ']';\n"
" if (el.tagName.toLowerCase() === 'input' && el.type) {\n"

View File

@@ -18,6 +18,7 @@
#include <string.h>
#include <stdlib.h>
#include <cairo.h>
#include <gdk/gdk.h>
/* ── JSON helpers ─────────────────────────────────────────────────── */
@@ -78,7 +79,151 @@ static WebKitWebView *get_active_webview(void) {
return tab ? tab->webview : NULL;
}
/* ── Resolve a ref or selector to a JS element query ──────────────── */
/* ── Coordinate-based click via GDK event synthesis ────────────────── */
/* Synthesize a real GDK button press + release at (x, y) in the webview's
* GdkWindow coordinate space. This triggers WebKit's native hit-testing
* and full event propagation (pointerdown/click), which is required for
* SPA frameworks (React, Radix UI) that ignore JS synthetic .click() calls.
*
* Coordinates from getBoundingClientRect() are relative to the viewport
* and match the webview's GdkWindow coordinate space when the webview
* fills its parent window. Returns TRUE on success. */
static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
if (wv == NULL) return FALSE;
GtkWidget *widget = GTK_WIDGET(wv);
GdkWindow *window = gtk_widget_get_window(widget);
if (window == NULL) return FALSE;
/* Process any pending events so the widget is in a consistent state. */
while (gtk_events_pending()) gtk_main_iteration();
/* --- Button press --- */
GdkEvent *press = gdk_event_new(GDK_BUTTON_PRESS);
press->button.window = g_object_ref(window);
press->button.send_event = TRUE;
press->button.time = GDK_CURRENT_TIME;
press->button.x = x;
press->button.y = y;
press->button.axes = NULL;
press->button.state = 0; /* no modifiers */
press->button.button = 1; /* left button */
press->button.device = gdk_seat_get_pointer(gdk_display_get_default_seat(gdk_window_get_display(window)));
gdk_event_put(press);
gdk_event_free(press);
/* Small delay between press and release so WebKit treats it as a click. */
g_usleep(10 * 1000); /* 10 ms — g_usleep is GLib's portable usleep */
/* Process the press before sending release. */
while (gtk_events_pending()) gtk_main_iteration();
/* --- Button release --- */
GdkEvent *release = gdk_event_new(GDK_BUTTON_RELEASE);
release->button.window = g_object_ref(window);
release->button.send_event = TRUE;
release->button.time = GDK_CURRENT_TIME;
release->button.x = x;
release->button.y = y;
release->button.axes = NULL;
release->button.state = 0;
release->button.button = 1;
release->button.device = gdk_seat_get_pointer(gdk_display_get_default_seat(gdk_window_get_display(window)));
gdk_event_put(release);
gdk_event_free(release);
/* Let the release propagate before we return. */
while (gtk_events_pending()) gtk_main_iteration();
return TRUE;
}
/* Synthesize real GDK key press + release events for each character in
* `text`, dispatching them to the webview's GdkWindow. This triggers
* WebKit's native text input handling, which properly fires React's
* onChange handlers (and other SPA framework input listeners) that
* ignore JS synthetic `element.value = ...` + `input` event dispatch.
*
* Returns TRUE if at least one key event was dispatched. */
static gboolean synthesize_type_text(WebKitWebView *wv, const char *text) {
if (wv == NULL || text == NULL) return FALSE;
GtkWidget *widget = GTK_WIDGET(wv);
GdkWindow *window = gtk_widget_get_window(widget);
if (window == NULL) return FALSE;
gboolean dispatched_any = FALSE;
for (const char *p = text; *p != '\0'; p = g_utf8_next_char(p)) {
gunichar uc = g_utf8_get_char(p);
guint keyval;
/* Map special characters to their GDK keyvals. */
if (uc == '\n') {
keyval = GDK_KEY_Return;
} else if (uc == '\t') {
keyval = GDK_KEY_Tab;
} else if (uc == '\r') {
/* Skip carriage returns — handled by \n. */
continue;
} else {
keyval = gdk_unicode_to_keyval(uc);
}
if (keyval == 0) continue;
/* --- Key press --- */
GdkEvent *press = gdk_event_new(GDK_KEY_PRESS);
press->key.window = g_object_ref(window);
press->key.send_event = TRUE;
press->key.time = GDK_CURRENT_TIME;
press->key.state = 0; /* no modifiers */
press->key.keyval = keyval;
press->key.length = 1;
press->key.string = g_strdup_printf("%c", (gchar)(uc & 0xFF));
press->key.group = 0;
press->key.hardware_keycode = 0;
gdk_event_put(press);
gdk_event_free(press);
/* Small delay so WebKit treats press/release as a keystroke. */
g_usleep(5 * 1000); /* 5 ms */
while (gtk_events_pending()) gtk_main_iteration();
/* --- Key release --- */
GdkEvent *release = gdk_event_new(GDK_KEY_RELEASE);
release->key.window = g_object_ref(window);
release->key.send_event = TRUE;
release->key.time = GDK_CURRENT_TIME;
release->key.state = 0;
release->key.keyval = keyval;
release->key.length = 1;
release->key.string = g_strdup_printf("%c", (gchar)(uc & 0xFF));
release->key.group = 0;
release->key.hardware_keycode = 0;
gdk_event_put(release);
gdk_event_free(release);
while (gtk_events_pending()) gtk_main_iteration();
dispatched_any = TRUE;
}
return dispatched_any;
}
/* ── Resolve a ref or selector to a JS element query ──────────────── *
* Stale-ref fallback strategy (for @eN refs):
* 1. Try the stored CSS selector — if it still matches, return it.
* 2. If the selector is stale (page re-rendered), re-resolve by
* role + name: querySelectorAll('[role="ROLE"]') then filter by
* textContent.indexOf("NAME") >= 0. Update the stored selector.
* 3. If role+name fails, fall back to the stored bounding box:
* document.elementFromPoint(centerX, centerY). Update the selector.
* 4. Return NULL if all strategies fail.
*
* On successful re-resolution the new selector is written back into
* window.__agentRefs[refId].selector so subsequent calls are fast. */
static char *resolve_ref_to_selector(const char *ref) {
if (ref == NULL || ref[0] == '\0') return NULL;
@@ -86,17 +231,84 @@ static char *resolve_ref_to_selector(const char *ref) {
/* If it starts with @, it's a ref from snapshot. */
if (ref[0] == '@') {
const char *ref_id = ref + 1;
/* Query the page's __agentRefs for the selector. */
char script[256];
snprintf(script, sizeof(script),
"(window.__agentRefs && window.__agentRefs['%s']) ? "
"window.__agentRefs['%s'].selector : null",
ref_id, ref_id);
WebKitWebView *wv = get_active_webview();
if (wv == NULL) return NULL;
char *esc_ref = g_strescape(ref_id, NULL);
char *script = g_strdup_printf(
"(function(){\n"
" var refId = \"%s\";\n"
" var refs = window.__agentRefs;\n"
" if (!refs || !refs[refId]) return null;\n"
" var entry = refs[refId];\n"
" /* 1. Try the stored selector first. */\n"
" if (entry.selector) {\n"
" try { if (document.querySelector(entry.selector)) return entry.selector; } catch(e) {}\n"
" }\n"
" /* Helper: build a unique CSS selector for an element. */\n"
" function uniq(el) {\n"
" if (el.id) return '#' + CSS.escape(el.id);\n"
" var path = [];\n"
" while (el && el.nodeType === 1 && el !== document.documentElement) {\n"
" var index = 1;\n"
" var sib = el.previousElementSibling;\n"
" while (sib) { if (sib.tagName === el.tagName) index++; sib = sib.previousElementSibling; }\n"
" var part = el.tagName.toLowerCase();\n"
" if (el.className && typeof el.className === 'string') {\n"
" var cls = el.className.trim().split(/\\s+/).slice(0,2).join('.');\n"
" if (cls) part += '.' + cls;\n"
" }\n"
" if (index > 1) part += ':nth-of-type(' + index + ')';\n"
" path.unshift(part);\n"
" el = el.parentElement;\n"
" }\n"
" return path.length > 0 ? path.join(' > ') : 'html';\n"
" }\n"
" /* 2. Re-resolve by role + name. */\n"
" if (entry.role && entry.name) {\n"
" var role = entry.role;\n"
" var name = entry.name;\n"
" var candidates = [];\n"
" var byAttr = document.querySelectorAll('[role=\"' + role + '\"]');\n"
" for (var i = 0; i < byAttr.length; i++) candidates.push(byAttr[i]);\n"
" if (candidates.length === 0) {\n"
" var tagMap = { 'button':'button','link':'a','textbox':'input',\n"
" 'combobox':'select','image':'img','heading':'h1',\n"
" 'navigation':'nav','main':'main','list':'ul','listitem':'li' };\n"
" if (tagMap[role]) {\n"
" var byTag = document.querySelectorAll(tagMap[role]);\n"
" for (var j = 0; j < byTag.length; j++) candidates.push(byTag[j]);\n"
" }\n"
" }\n"
" for (var k = 0; k < candidates.length; k++) {\n"
" var c = candidates[k];\n"
" var al = c.getAttribute('aria-label') || '';\n"
" var tc = c.textContent.trim();\n"
" if (al.indexOf(name) >= 0 || tc.indexOf(name) >= 0) {\n"
" var newSel = uniq(c);\n"
" entry.selector = newSel;\n"
" return newSel;\n"
" }\n"
" }\n"
" }\n"
" /* 3. Re-resolve by bounding box via elementFromPoint. */\n"
" if (entry.bbox && entry.bbox.width > 0 && entry.bbox.height > 0) {\n"
" var cx = entry.bbox.x + entry.bbox.width / 2;\n"
" var cy = entry.bbox.y + entry.bbox.height / 2;\n"
" var el = document.elementFromPoint(cx, cy);\n"
" if (el) {\n"
" var newSel = uniq(el);\n"
" entry.selector = newSel;\n"
" return newSel;\n"
" }\n"
" }\n"
" return null;\n"
"})();",
esc_ref);
g_free(esc_ref);
char *result = agent_js_eval_sync(wv, script, 5000);
g_free(script);
if (result == NULL || result[0] == '\0' || strcmp(result, "null") == 0) {
g_free(result);
return NULL;
@@ -776,8 +988,70 @@ static cJSON *tool_click(cJSON *params) {
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
/* --- Coordinate-based click via GDK event synthesis ---
* Resolve the element's bounding box via getBoundingClientRect(),
* then synthesize a real GDK button press/release at the center.
* This triggers WebKit's native hit-testing and full event
* propagation, which is required for SPA frameworks (React, Radix
* UI) that ignore JS synthetic .click() calls. Falls back to the
* JS .click() approach if the bounding box cannot be retrieved. */
/* Escape the selector for safe embedding in a JS string literal.
* g_strescape() escapes backslashes and quotes, which handles
* selectors containing brackets like min-h-[60vh]. */
char *esc_sel = g_strescape(sel, NULL);
char *box_js = g_strdup_printf(
"(function(){var el=document.querySelector(\"%s\");"
"if(!el)return null;"
"var r=el.getBoundingClientRect();"
"return JSON.stringify({x:r.x,y:r.y,width:r.width,height:r.height});"
"})();",
esc_sel);
g_free(esc_sel);
char *box_result = agent_js_eval_sync(wv, box_js, 5000);
g_free(box_js);
if (box_result && box_result[0] != '\0' && strcmp(box_result, "null") != 0) {
cJSON *box = cJSON_Parse(box_result);
g_free(box_result);
if (box) {
cJSON *jx = cJSON_GetObjectItem(box, "x");
cJSON *jy = cJSON_GetObjectItem(box, "y");
cJSON *jw = cJSON_GetObjectItem(box, "width");
cJSON *jh = cJSON_GetObjectItem(box, "height");
if (jx && jy && jw && jh && cJSON_IsNumber(jx) && cJSON_IsNumber(jy) &&
cJSON_IsNumber(jw) && cJSON_IsNumber(jh)) {
double cx = jx->valuedouble + jw->valuedouble / 2.0;
double cy = jy->valuedouble + jh->valuedouble / 2.0;
cJSON_Delete(box);
g_free(sel);
if (synthesize_click_at(wv, cx, cy)) {
return make_success(NULL);
}
/* Fall through to JS fallback if GDK synthesis failed. */
/* Re-resolve sel since we freed it above. */
if (ref && ref[0]) {
sel = resolve_ref_to_selector(ref);
} else if (selector && selector[0]) {
sel = g_strdup(selector);
} else {
return make_error("CLICK_FAILED", "GDK event synthesis failed and no selector for fallback");
}
if (!sel) return make_error("CLICK_FAILED", "GDK event synthesis failed");
wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
} else {
cJSON_Delete(box);
}
}
} else {
g_free(box_result);
}
/* --- Fallback: JS .click() --- */
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"(function() { var el = document.querySelector('%s'); "
"if (el) { el.click(); return 'ok'; } return null; })();",
sel);
char *result = agent_js_eval_sync(wv, script, 5000);
@@ -793,6 +1067,29 @@ static cJSON *tool_click(cJSON *params) {
return make_success(NULL);
}
/* click_at — Click at explicit viewport coordinates via GDK event
* synthesis. Useful when the agent already knows the target point
* (e.g. from a screenshot or get_box result) and wants to bypass
* selector resolution entirely. */
static cJSON *tool_click_at(cJSON *params) {
cJSON *jx = cJSON_GetObjectItem(params, "x");
cJSON *jy = cJSON_GetObjectItem(params, "y");
if (!jx || !jy || !cJSON_IsNumber(jx) || !cJSON_IsNumber(jy)) {
return make_error("MISSING_PARAM", "Provide numeric 'x' and 'y' coordinates");
}
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
double x = jx->valuedouble;
double y = jy->valuedouble;
if (!synthesize_click_at(wv, x, y)) {
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
}
return make_success(NULL);
}
static cJSON *tool_fill(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
@@ -813,10 +1110,31 @@ static cJSON *tool_fill(cJSON *params) {
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
/* Escape the value for JS string. */
/* Focus the element and clear its value via JS, then type via GDK key
* events for SPA compatibility. */
char *esc_sel = g_strescape(sel, NULL);
char *focus_js = g_strdup_printf(
"(function(){var el=document.querySelector(\"%s\");if(!el)return 'nofocus';"
"el.focus();el.value=\"\";"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"return 'ok';})();",
esc_sel);
g_free(esc_sel);
char *focus_result = agent_js_eval_sync(wv, focus_js, 5000);
g_free(focus_js);
gboolean focused = (focus_result && strcmp(focus_result, "ok") == 0);
g_free(focus_result);
if (focused && synthesize_type_text(wv, value)) {
g_free(sel);
return make_success(NULL);
}
/* Fallback: legacy JS .value= approach (with proper selector quoting). */
char *escaped = g_strescape(value, NULL);
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"(function() { var el = document.querySelector(\"%s\"); "
"if (el) { el.value = \"%s\"; el.dispatchEvent(new Event('input', {bubbles:true})); "
"el.dispatchEvent(new Event('change', {bubbles:true})); return 'ok'; } return null; })();",
sel, escaped);
@@ -838,6 +1156,7 @@ static cJSON *tool_type(cJSON *params) {
const char *ref = get_string_param(params, "ref");
const char *selector = get_string_param(params, "selector");
const char *value = get_string_param(params, "value");
gboolean do_clear = get_bool_param(params, "clear", FALSE);
if (!value) return make_error("MISSING_PARAM", "Provide 'value'");
@@ -854,9 +1173,31 @@ static cJSON *tool_type(cJSON *params) {
WebKitWebView *wv = get_active_webview();
if (!wv) { g_free(sel); return make_error("NO_TAB", "No active tab"); }
/* Focus the element via JS, then type via GDK key events. */
char *esc_sel = g_strescape(sel, NULL);
char *focus_js = g_strdup_printf(
"(function(){var el=document.querySelector(\"%s\");if(!el)return 'nofocus';"
"el.focus();%s"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"return 'ok';})();",
esc_sel,
do_clear ? "el.value=\"\";" : "");
g_free(esc_sel);
char *focus_result = agent_js_eval_sync(wv, focus_js, 5000);
g_free(focus_js);
gboolean focused = (focus_result && strcmp(focus_result, "ok") == 0);
g_free(focus_result);
if (focused && synthesize_type_text(wv, value)) {
g_free(sel);
return make_success(NULL);
}
/* Fallback: legacy JS .value+= approach (with proper selector quoting). */
char *escaped = g_strescape(value, NULL);
char *script = g_strdup_printf(
"(function() { var el = document.querySelector(%s); "
"(function() { var el = document.querySelector(\"%s\"); "
"if (el) { el.value += \"%s\"; el.dispatchEvent(new Event('input', {bubbles:true})); "
"return 'ok'; } return null; })();",
sel, escaped);
@@ -1644,8 +1985,11 @@ static const char *FIND_HELPER_JS =
" var selector = uniqueSelector(el);\n"
" var role = el.getAttribute('role') || el.tagName.toLowerCase();\n"
" var name = el.getAttribute('aria-label') || el.textContent.trim().substring(0, 100) || '';\n"
" window.__agentRefs[refId] = { selector: selector, role: role, name: name };\n"
" return JSON.stringify({ ref: '@' + refId, selector: selector, role: role, name: name });\n"
" var rect = el.getBoundingClientRect();\n"
" window.__agentRefs[refId] = { selector: selector, role: role, name: name,\n"
" bbox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };\n"
" return JSON.stringify({ ref: '@' + refId, selector: selector, role: role, name: name,\n"
" bbox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } });\n"
" }\n";
/* find_role — Find element by ARIA role (and optionally by name) */
@@ -3603,6 +3947,7 @@ static tool_entry_t tool_table[] = {
/* Interaction tools */
{"click", tool_click, TRUE},
{"click_at", tool_click_at, TRUE},
{"fill", tool_fill, TRUE},
{"type", tool_type, TRUE},
{"press", tool_press, TRUE},
@@ -3963,12 +4308,71 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
return make_error("EVAL_FAILED", "Failed to start async is_checked");
}
/* click — async JS */
/* click — coordinate-based via GDK event synthesis, with JS fallback.
* We first resolve the element's bounding box via getBoundingClientRect()
* (synchronous JS eval), then synthesize a real GDK button press/release
* at the center. This triggers WebKit's native hit-testing and full
* event propagation, which is required for SPA frameworks (React, Radix
* UI) that ignore JS synthetic .click() calls. If the bounding box
* cannot be retrieved, we fall back to the async JS .click() path. */
if (conn != NULL && strcmp(tool_name, "click") == 0) {
WebKitWebView *wv = get_active_webview();
if (wv == NULL) return make_error("NO_TAB", "No active tab");
char *sel = resolve_ref_async(params);
if (sel == NULL) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
/* Escape the selector for safe embedding in a JS string literal. */
char *esc_sel = g_strescape(sel, NULL);
char *box_js = g_strdup_printf(
"(function(){var el=document.querySelector(\"%s\");"
"if(!el)return null;"
"var r=el.getBoundingClientRect();"
"return JSON.stringify({x:r.x,y:r.y,width:r.width,height:r.height});"
"})();",
esc_sel);
g_free(esc_sel);
char *box_result = agent_js_eval_sync(wv, box_js, 5000);
g_free(box_js);
gboolean coord_click_done = FALSE;
if (box_result && box_result[0] != '\0' && strcmp(box_result, "null") != 0) {
cJSON *box = cJSON_Parse(box_result);
if (box) {
cJSON *jx = cJSON_GetObjectItem(box, "x");
cJSON *jy = cJSON_GetObjectItem(box, "y");
cJSON *jw = cJSON_GetObjectItem(box, "width");
cJSON *jh = cJSON_GetObjectItem(box, "height");
if (jx && jy && jw && jh && cJSON_IsNumber(jx) && cJSON_IsNumber(jy) &&
cJSON_IsNumber(jw) && cJSON_IsNumber(jh)) {
double cx = jx->valuedouble + jw->valuedouble / 2.0;
double cy = jy->valuedouble + jh->valuedouble / 2.0;
cJSON_Delete(box);
g_free(box_result);
if (synthesize_click_at(wv, cx, cy)) {
g_free(sel);
/* Send a success response directly through the
* WebSocket connection, matching the async path. */
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
cJSON_AddNumberToObject(resp, "id", request_id);
char *str = cJSON_PrintUnformatted(resp);
if (str) {
soup_websocket_connection_send_text(conn, str);
free(str);
}
cJSON_Delete(resp);
return NULL;
}
coord_click_done = FALSE; /* synthesis failed, fall through */
} else {
cJSON_Delete(box);
}
}
}
if (!coord_click_done) g_free(box_result);
/* --- Fallback: async JS .click() --- */
char *script = g_strdup_printf(
"(function(){var el=document.querySelector('%s');if(el){el.click();return 'ok';}return null;})();",
sel);
@@ -3981,7 +4385,37 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
return make_error("EVAL_FAILED", "Failed to start async click");
}
/* fill — async JS */
/* click_at — coordinate-based click via GDK event synthesis (sync).
* Takes explicit x/y viewport coordinates and dispatches a real
* GDK button press/release. Bypasses selector resolution entirely. */
if (conn != NULL && strcmp(tool_name, "click_at") == 0) {
cJSON *jx = cJSON_GetObjectItem(params, "x");
cJSON *jy = cJSON_GetObjectItem(params, "y");
if (!jx || !jy || !cJSON_IsNumber(jx) || !cJSON_IsNumber(jy)) {
return make_error("MISSING_PARAM", "Provide numeric 'x' and 'y' coordinates");
}
WebKitWebView *wv = get_active_webview();
if (wv == NULL) return make_error("NO_TAB", "No active tab");
double x = jx->valuedouble;
double y = jy->valuedouble;
if (!synthesize_click_at(wv, x, y)) {
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
}
/* Send success response directly through the WebSocket. */
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
cJSON_AddNumberToObject(resp, "id", request_id);
char *str = cJSON_PrintUnformatted(resp);
if (str) {
soup_websocket_connection_send_text(conn, str);
free(str);
}
cJSON_Delete(resp);
return NULL;
}
/* fill — focus + clear via JS, then type via GDK key events (SPA-safe).
* Falls back to the legacy JS .value= approach if GDK synthesis fails. */
if (conn != NULL && strcmp(tool_name, "fill") == 0) {
WebKitWebView *wv = get_active_webview();
if (wv == NULL) return make_error("NO_TAB", "No active tab");
@@ -3989,6 +4423,39 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
if (!value) return make_error("MISSING_PARAM", "Provide 'value'");
char *sel = resolve_ref_async(params);
if (sel == NULL) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
/* Focus the element and clear its value via JS so WebKit's input
* focus state is established before we synthesize key events. */
char *esc_sel = g_strescape(sel, NULL);
char *focus_js = g_strdup_printf(
"(function(){var el=document.querySelector(\"%s\");if(!el)return 'nofocus';"
"el.focus();el.value=\"\";"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"return 'ok';})();",
esc_sel);
g_free(esc_sel);
char *focus_result = agent_js_eval_sync(wv, focus_js, 5000);
g_free(focus_js);
gboolean focused = (focus_result && strcmp(focus_result, "ok") == 0);
g_free(focus_result);
if (focused && synthesize_type_text(wv, value)) {
g_free(sel);
/* Send success response directly through the WebSocket. */
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
cJSON_AddNumberToObject(resp, "id", request_id);
char *str = cJSON_PrintUnformatted(resp);
if (str) {
soup_websocket_connection_send_text(conn, str);
free(str);
}
cJSON_Delete(resp);
return NULL;
}
/* --- Fallback: legacy async JS .value= approach --- */
char *escaped = g_strescape(value, NULL);
char *script = g_strdup_printf(
"(function(){var el=document.querySelector('%s');if(el){el.value=\"%s\";"
@@ -4005,14 +4472,50 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
return make_error("EVAL_FAILED", "Failed to start async fill");
}
/* type — async JS */
/* type — focus via JS, then type via GDK key events (SPA-safe).
* Optionally clears the field first if 'clear' is true.
* Falls back to the legacy JS .value+= approach if GDK synthesis fails. */
if (conn != NULL && strcmp(tool_name, "type") == 0) {
WebKitWebView *wv = get_active_webview();
if (wv == NULL) return make_error("NO_TAB", "No active tab");
const char *value = get_string_param(params, "value");
if (!value) return make_error("MISSING_PARAM", "Provide 'value'");
gboolean do_clear = get_bool_param(params, "clear", FALSE);
char *sel = resolve_ref_async(params);
if (sel == NULL) return make_error("REF_NOT_FOUND", "No element with that ref. Take a new snapshot.");
/* Focus the element; optionally clear its value via JS. */
char *esc_sel = g_strescape(sel, NULL);
char *focus_js = g_strdup_printf(
"(function(){var el=document.querySelector(\"%s\");if(!el)return 'nofocus';"
"el.focus();%s"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"return 'ok';})();",
esc_sel,
do_clear ? "el.value=\"\";" : "");
g_free(esc_sel);
char *focus_result = agent_js_eval_sync(wv, focus_js, 5000);
g_free(focus_js);
gboolean focused = (focus_result && strcmp(focus_result, "ok") == 0);
g_free(focus_result);
if (focused && synthesize_type_text(wv, value)) {
g_free(sel);
/* Send success response directly through the WebSocket. */
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
cJSON_AddNumberToObject(resp, "id", request_id);
char *str = cJSON_PrintUnformatted(resp);
if (str) {
soup_websocket_connection_send_text(conn, str);
free(str);
}
cJSON_Delete(resp);
return NULL;
}
/* --- Fallback: legacy async JS .value+= approach --- */
char *escaped = g_strescape(value, NULL);
char *script = g_strdup_printf(
"(function(){var el=document.querySelector('%s');if(el){el.value+=\"%s\";"

View File

@@ -241,7 +241,8 @@ void on_menu_profile(GtkMenuItem *item, gpointer data) {
* sovereign://settings page (key-capture UI) and persisted to SQLite.
*/
static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
/* Made non-static so tab_manager.c can connect it to each webview. */
gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data) {
(void)widget;
(void)data;
@@ -337,6 +338,10 @@ static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
return TRUE;
}
case SHORTCUT_TOGGLE_INSPECTOR:
tab_manager_toggle_inspector();
return TRUE;
default:
return FALSE;
}
@@ -621,10 +626,12 @@ int main(int argc, char **argv) {
/* ── Security strip: disable web security restrictions ─────── */
WebKitSecurityManager *sec_mgr =
webkit_web_context_get_security_manager(web_ctx);
/* Register sovereign:// as secure only. Do NOT register it as "local"
* (WebKit blocks fetch from https to local origins) and do NOT register
* it as "cors_enabled" (that makes WebKit enforce CORS response headers,
* which we can't set with the basic finish API). Without these, WebKit
* treats sovereign:// as a simple secure scheme with no CORS checks. */
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr,
"sovereign");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");

View File

@@ -363,6 +363,157 @@ static void handle_get_relays(WebKitURISchemeRequest *request) {
free(json);
}
/* ── Shared page CSS / theme helpers ────────────────────────────── *
* The sovereign:// internal pages (settings, profile, bookmarks) use
* a shared CSS block that mirrors the client aesthetic: monospace
* font, a minimal black/white/red palette, grayscale images, and a
* Night/Day toggle driven by the `theme_dark` setting.
*
* Colors are expressed as CSS custom properties on :root and flipped
* by a `dark` class on <html>, exactly like client.css. */
/* Return "dark" or "light" based on the saved theme preference. */
static const char *sovereign_theme_class(void) {
return settings_get()->theme_dark ? "dark" : "light";
}
/* Return the shared <style> block (static string, do not free). */
static const char *sovereign_page_css(void) {
return
" :root {\n"
" --font: monospace;\n"
" --primary: #000000;\n"
" --secondary: #ffffff;\n"
" --accent: #ff0000;\n"
" --muted: #dddddd;\n"
" --border: var(--muted);\n"
" --radius: 5px;\n"
" --bg: var(--secondary);\n"
" --fg: var(--primary);\n"
" --img-gray: 100%;\n"
" --img-gray-hover: 20%;\n"
" color-scheme: light;\n"
" }\n"
" html.dark {\n"
" --primary: #ffffff;\n"
" --secondary: #000000;\n"
" --accent: #ff0000;\n"
" --muted: #777777;\n"
" --border: var(--muted);\n"
" color-scheme: dark;\n"
" }\n"
" * { font-family: var(--font); margin: 0; padding: 0; box-sizing: border-box; }\n"
" html { transition: background-color 0.2s ease, color 0.2s ease; }\n"
" body { color: var(--fg); background: var(--bg); max-width: 800px;\n"
" margin: 40px auto; padding: 20px; line-height: 1.5; }\n"
" h1 { color: var(--primary); text-align: center;\n"
" border-bottom: 2px solid var(--primary);\n"
" padding-bottom: 8px; margin-bottom: 20px; }\n"
" h2 { color: var(--primary); margin-top: 30px; margin-bottom: 10px;\n"
" border-bottom: 1px solid var(--border); padding-bottom: 4px; }\n"
" h3 { color: var(--primary); margin-top: 20px; margin-bottom: 8px; }\n"
" a { color: var(--accent); text-decoration: none; transition: 0.2s; }\n"
" a:hover { text-decoration: underline; }\n"
" img { filter: grayscale(var(--img-gray)); transition: filter 0.2s; }\n"
" img:hover { filter: grayscale(var(--img-gray-hover)); }\n"
" .note { color: var(--muted); font-size: 12px; margin: 10px 0; }\n"
" .info { color: var(--muted); font-size: 12px; word-break: break-all; }\n"
" .missing { color: var(--muted); font-style: italic; }\n"
" .info-row { display: flex; justify-content: space-between;\n"
" gap: 20px; padding: 6px 0;\n"
" border-bottom: 1px solid var(--border); }\n"
" .info-row span:first-child { font-weight: bold; flex-shrink: 0; }\n"
" .setting, .field { display: flex; justify-content: space-between;\n"
" align-items: center; gap: 20px; padding: 12px 0;\n"
" border-bottom: 1px solid var(--border); }\n"
" .setting-name { font-weight: bold; }\n"
" .setting-desc { color: var(--muted); font-size: 12px; margin-top: 4px; }\n"
" .toggle { position: relative; width: 50px; height: 24px;\n"
" background: var(--muted); border-radius: 12px; cursor: pointer;\n"
" transition: background 0.2s; flex-shrink: 0; }\n"
" .toggle.on { background: var(--primary); }\n"
" .toggle.off { background: var(--muted); }\n"
" .toggle::after { content: ''; position: absolute; top: 2px; left: 2px;\n"
" width: 20px; height: 20px; border-radius: 50%; background: var(--secondary);\n"
" transition: transform 0.2s; }\n"
" .toggle.on::after { transform: translateX(26px); }\n"
" input, select, textarea {\n"
" background: var(--bg); color: var(--fg);\n"
" border: 1px solid var(--border); border-radius: var(--radius);\n"
" padding: 6px 10px; font-family: var(--font); font-size: 13px;\n"
" min-width: 200px; }\n"
" input:focus, select:focus, textarea:focus {\n"
" border-color: var(--accent); outline: none; }\n"
" .btn, .save-btn { background: var(--bg); color: var(--primary);\n"
" border: 1px solid var(--primary); border-radius: var(--radius);\n"
" padding: 6px 16px; cursor: pointer; font-family: var(--font);\n"
" font-size: 13px; font-weight: bold; margin-left: 8px;\n"
" transition: border-color 0.2s, background 0.2s, color 0.2s; }\n"
" .btn:hover, .save-btn:hover { border-color: var(--accent); }\n"
" .btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }\n"
" .btn:disabled { opacity: 0.5; cursor: not-allowed; }\n"
" .btn-del { border-color: var(--accent); color: var(--accent); }\n"
" .btn-del:hover { background: var(--accent); color: var(--secondary); }\n"
" .status { padding: 8px; margin: 10px 0; border-radius: var(--radius);\n"
" display: none; border: 1px solid var(--border); }\n"
" .status.show { display: block; }\n"
" .status.ok { border-color: var(--primary); color: var(--primary); }\n"
" .status.err { border-color: var(--accent); color: var(--accent); }\n"
" .shortcut-display { display: inline-block; min-width: 120px;\n"
" padding: 4px 10px; background: var(--bg); border: 1px solid var(--border);\n"
" border-radius: var(--radius); font-family: var(--font); font-size: 13px;\n"
" text-align: center; }\n"
" .shortcut-display.capturing { border-color: var(--accent);\n"
" animation: pulse 1s infinite; }\n"
" @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }\n"
" .shortcut-row.duplicate .shortcut-display { border-color: var(--accent); }\n"
" .sc-change, .sc-reset { min-width: 70px; }\n"
" .qr { text-align: center; margin: 12px 0; }\n"
" .qr img { image-rendering: pixelated; border: 4px solid var(--secondary);\n"
" border-radius: 4px; }\n"
" .theme-toggle { position: fixed; top: 20px; right: 20px;\n"
" display: flex; align-items: center; gap: 8px; cursor: pointer;\n"
" font-size: 12px; color: var(--muted); user-select: none;\n"
" border: 1px solid var(--border); border-radius: var(--radius);\n"
" padding: 4px 10px; background: var(--bg); z-index: 10; }\n"
" .theme-toggle:hover { border-color: var(--accent); color: var(--accent); }\n"
" .bm { display: flex; justify-content: space-between; align-items: center;\n"
" gap: 20px; padding: 8px 0; border-bottom: 1px solid var(--border); }\n"
" .bm-url { color: var(--accent); text-decoration: none; word-break: break-all; }\n"
" .bm-url:hover { text-decoration: underline; }\n"
" .bm-title { color: var(--primary); font-weight: bold; }\n"
" .bm-meta { color: var(--muted); font-size: 11px; }\n"
" .actions { display: flex; gap: 8px; flex-shrink: 0; }\n"
" .form { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }\n"
" .empty { color: var(--muted); font-style: italic; padding: 8px 0; }\n"
" .dir-header { display: flex; justify-content: space-between;\n"
" align-items: center; gap: 20px; }\n";
}
/* Build the opening <html><head> with the shared CSS and theme class.
* Returns a newly-allocated string (g_free). */
static char *sovereign_page_head(const char *title) {
return g_strdup_printf(
"<!DOCTYPE html>\n"
"<html class='%s'><head><meta charset='UTF-8'>\n"
"<meta name='viewport' content='width=device-width, initial-scale=1'>\n"
"<title>%s</title>\n"
"<style>\n%s</style>\n"
"</head><body>\n",
sovereign_theme_class(), title, sovereign_page_css());
}
/* Build the theme-toggle button + closing </body></html>.
* Returns a newly-allocated string (g_free). */
static char *sovereign_page_foot(void) {
return g_strdup_printf(
"<div class='theme-toggle' onclick='document.location.href="
"\"sovereign://settings/set?feature=theme_dark\"'>"
"%s &#9728;&#9790;</div>\n"
"</body></html>\n",
settings_get()->theme_dark ? "Day" : "Night");
}
/* ── Settings page ─────────────────────────────────────────────── */
/* Helper: extract a query parameter value (key=...) from a query string.
@@ -467,61 +618,19 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
"</div>\n");
/* Build the HTML page. */
char *head = sovereign_page_head("Settings");
char *foot = sovereign_page_foot();
char *html = g_strdup_printf(
"<!DOCTYPE html>\n"
"<html><head><meta charset='UTF-8'>\n"
"<title>sovereign browser — Settings</title>\n"
"<style>\n"
" body { font-family: monospace; max-width: 700px; margin: 40px auto;\n"
" padding: 20px; background: #1a1a1a; color: #e0e0e0; }\n"
" h1 { color: #ff4444; border-bottom: 2px solid #ff4444; padding-bottom: 8px; }\n"
" h2 { color: #ff8888; margin-top: 30px; }\n"
" .setting { display: flex; justify-content: space-between;\n"
" align-items: center; padding: 12px 0;\n"
" border-bottom: 1px solid #333; }\n"
" .setting-name { font-weight: bold; }\n"
" .setting-desc { color: #888; font-size: 12px; margin-top: 4px; }\n"
" .toggle { position: relative; width: 50px; height: 24px;\n"
" background: #333; border-radius: 12px; cursor: pointer;\n"
" transition: background 0.2s; }\n"
" .toggle.on { background: #2a5a2a; }\n"
" .toggle.off { background: #5a2a2a; }\n"
" .toggle::after { content: ''; position: absolute; top: 2px; left: 2px;\n"
" width: 20px; height: 20px; border-radius: 50%%; background: #e0e0e0;\n"
" transition: transform 0.2s; }\n"
" .toggle.on::after { transform: translateX(26px); }\n"
" .field { display: flex; justify-content: space-between;\n"
" align-items: center; padding: 12px 0;\n"
" border-bottom: 1px solid #333; }\n"
" .field input, .field select {\n"
" background: #2a2a2a; color: #e0e0e0; border: 1px solid #444;\n"
" border-radius: 4px; padding: 4px 8px; font-family: monospace;\n"
" font-size: 13px; min-width: 200px; }\n"
" .field input:focus, .field select:focus { border-color: #ff4444; }\n"
" .save-btn { background: #ff4444; color: #fff; border: none;\n"
" border-radius: 4px; padding: 6px 16px; cursor: pointer;\n"
" font-family: monospace; font-size: 13px; margin-left: 8px; }\n"
" .save-btn:hover { background: #ff6666; }\n"
" .status { padding: 8px; margin: 10px 0; border-radius: 4px; display: none; }\n"
" .status.show { display: block; }\n"
" .status.ok { background: #1a3a1a; color: #44ff44; }\n"
" .status.err { background: #3a1a1a; color: #ff4444; }\n"
" .note { color: #888; font-size: 12px; margin: 10px 0 20px 0; }\n"
" .info { color: #888; font-size: 12px; }\n"
" .info-row { display: flex; justify-content: space-between; padding: 6px 0; }\n"
" .shortcut-display { display: inline-block; min-width: 120px;\n"
" padding: 4px 10px; background: #2a2a2a; border: 1px solid #444;\n"
" border-radius: 4px; font-family: monospace; font-size: 13px;\n"
" text-align: center; }\n"
" .shortcut-display.capturing { border-color: #ff4444;\n"
" background: #3a1a1a; animation: pulse 1s infinite; }\n"
" @keyframes pulse { 0%%,100%%{opacity:1} 50%%{opacity:0.5} }\n"
" .shortcut-row.duplicate .shortcut-display { border-color: #ff4444;\n"
" background: #3a1a1a; }\n"
" .sc-change, .sc-reset { min-width: 70px; }\n"
"</style>\n"
"</head><body>\n"
"<h1>sovereign browser — Settings</h1>\n"
"%s"
"<h1>Settings</h1>\n"
"\n"
"<h2>Appearance</h2>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Dark mode (Night)</div>\n"
" <div class='setting-desc'>Black background, white text. Toggle for Day mode.</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"theme_dark\")'></div>\n"
"</div>\n"
"\n"
"<h2>Tabs</h2>\n"
"\n"
@@ -864,7 +973,9 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
" checkDuplicates();\n"
" });\n"
"</script>\n"
"</body></html>\n",
"%s",
head,
bs->theme_dark ? "on" : "off",
bs->restore_session ? "on" : "off",
bs->new_tab_url,
strcmp(pos_str, "top") == 0 ? " selected" : "",
@@ -890,6 +1001,8 @@ static void handle_settings_page(WebKitURISchemeRequest *request) {
respond_html(request, html);
g_free(html);
g_free(head);
g_free(foot);
g_free(relays_escaped);
g_string_free(search_engine_options, TRUE);
g_string_free(shortcuts_html, TRUE);
@@ -952,6 +1065,11 @@ static void handle_settings_set(WebKitURISchemeRequest *request,
new_state = bs->tab_drag_reorder;
settings_save();
tab_manager_apply_settings();
} else if (strcmp(feature, "theme_dark") == 0) {
bs->theme_dark = !bs->theme_dark;
new_state = bs->theme_dark;
settings_save();
reload = 1;
} else if (strcmp(feature, "agent_server_enabled") == 0) {
bs->agent_server_enabled = !bs->agent_server_enabled;
new_state = bs->agent_server_enabled;
@@ -1143,15 +1261,18 @@ static void handle_settings_set(WebKitURISchemeRequest *request,
* that the post-login relay fetch worked correctly. */
static void handle_profile_page(WebKitURISchemeRequest *request) {
if (g_bridge.pubkey_hex[0] == '\0') {
respond_html(request,
"<!DOCTYPE html><html><head><meta charset='UTF-8'>"
"<style>body{font-family:monospace;max-width:700px;margin:40px auto;"
"padding:20px;background:#1a1a1a;color:#e0e0e0;}"
"h1{color:#ff4444;border-bottom:2px solid #ff4444;padding-bottom:8px;}"
".note{color:#888;}</style></head><body>"
"<h1>sovereign browser — Profile</h1>"
char *head = sovereign_page_head("Profile");
char *foot = sovereign_page_foot();
char *html = g_strdup_printf(
"%s"
"<h1>Profile</h1>"
"<p class='note'>No identity loaded. Log in to see your profile.</p>"
"</body></html>");
"%s",
head, foot);
respond_html(request, html);
g_free(html);
g_free(head);
g_free(foot);
return;
}
@@ -1271,24 +1392,11 @@ static void handle_profile_page(WebKitURISchemeRequest *request) {
const char *k3_missing = kind3 ? "" :
"<p class='missing'>(no kind 3 event in database)</p>\n";
char *head = sovereign_page_head("Profile");
char *foot = sovereign_page_foot();
char *html = g_strdup_printf(
"<!DOCTYPE html>\n"
"<html><head><meta charset='UTF-8'>\n"
"<title>sovereign browser — Profile</title>\n"
"<style>\n"
" body { font-family: monospace; max-width: 700px; margin: 40px auto;\n"
" padding: 20px; background: #1a1a1a; color: #e0e0e0; }\n"
" h1 { color: #ff4444; border-bottom: 2px solid #ff4444; padding-bottom: 8px; }\n"
" h2 { color: #ff8888; margin-top: 30px; }\n"
" .info-row { display: flex; justify-content: space-between; padding: 6px 0; }\n"
" .info { color: #888; font-size: 12px; word-break: break-all; }\n"
" .note { color: #888; font-size: 12px; margin: 10px 0; }\n"
" .missing { color: #5a2a2a; font-style: italic; }\n"
" .qr { text-align: center; margin: 12px 0; }\n"
" .qr img { image-rendering: pixelated; border: 4px solid #fff; border-radius: 4px; }\n"
"</style>\n"
"</head><body>\n"
"<h1>sovereign browser — Profile</h1>\n"
"%s"
"<h1>Profile</h1>\n"
"%s"
"<h2>Identity</h2>\n"
" <div class='info-row'><span>Pubkey</span>"
@@ -1334,7 +1442,8 @@ static void handle_profile_page(WebKitURISchemeRequest *request) {
"from the bootstrap relay fetch. If fields show <span class='missing'>"
"(not found)</span>, the relay fetch hasn't completed or the user has no "
"events of that kind on the bootstrap relays.</p>\n"
"</body></html>\n",
"%s",
head,
picture_html,
g_bridge.pubkey_hex,
esc_npub,
@@ -1358,6 +1467,8 @@ static void handle_profile_page(WebKitURISchemeRequest *request) {
respond_html(request, html);
g_free(html);
g_free(head);
g_free(foot);
g_free(qr_html);
g_free(esc_name);
g_free(esc_dname);
@@ -1384,43 +1495,11 @@ static void handle_bookmarks_page(WebKitURISchemeRequest *request) {
int have_signer = (g_bridge.signer != NULL && !g_bridge.readonly);
GString *html = g_string_new(
"<!DOCTYPE html>\n"
"<html><head><meta charset='UTF-8'>\n"
"<title>sovereign browser — Bookmarks</title>\n"
"<style>\n"
" body { font-family: monospace; max-width: 800px; margin: 40px auto;\n"
" padding: 20px; background: #1a1a1a; color: #e0e0e0; }\n"
" h1 { color: #ff4444; border-bottom: 2px solid #ff4444; padding-bottom: 8px; }\n"
" h2 { color: #ff8888; margin-top: 30px; }\n"
" h3 { color: #ffaaaa; margin-top: 20px; margin-bottom: 8px; }\n"
" .bm { display: flex; justify-content: space-between;\n"
" align-items: center; padding: 8px 0;\n"
" border-bottom: 1px solid #333; }\n"
" .bm-url { color: #88ccff; text-decoration: none; word-break: break-all; }\n"
" .bm-url:hover { text-decoration: underline; }\n"
" .bm-title { color: #e0e0e0; font-weight: bold; }\n"
" .bm-meta { color: #888; font-size: 11px; }\n"
" .actions { display: flex; gap: 8px; }\n"
" .btn { background: #333; color: #e0e0e0; border: 1px solid #555;\n"
" border-radius: 4px; padding: 3px 10px; cursor: pointer;\n"
" font-family: monospace; font-size: 12px; text-decoration: none; }\n"
" .btn:hover { background: #444; }\n"
" .btn-del { background: #5a2a2a; border-color: #7a3a3a; }\n"
" .btn-del:hover { background: #6a3a3a; }\n"
" .btn:disabled { opacity: 0.4; cursor: not-allowed; }\n"
" .form { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }\n"
" .form input, .form select {\n"
" background: #2a2a2a; color: #e0e0e0; border: 1px solid #444;\n"
" border-radius: 4px; padding: 4px 8px; font-family: monospace;\n"
" font-size: 13px; }\n"
" .note { color: #888; font-size: 12px; margin: 10px 0; }\n"
" .empty { color: #666; font-style: italic; padding: 8px 0; }\n"
" .dir-header { display: flex; justify-content: space-between;\n"
" align-items: center; }\n"
"</style>\n"
"</head><body>\n"
"<h1>sovereign browser — Bookmarks</h1>\n");
char *head = sovereign_page_head("Bookmarks");
GString *html = g_string_new(head);
g_free(head);
g_string_append(html,
"<h1>Bookmarks</h1>\n");
if (!have_signer) {
g_string_append(html,
@@ -1531,8 +1610,11 @@ static void handle_bookmarks_page(WebKitURISchemeRequest *request) {
" if (!name) { alert('Directory name is required'); return; }\n"
" location.href = 'sovereign://bookmarks/createdir?name=' + name;\n"
" }\n"
"</script>\n"
"</body></html>\n");
"</script>\n");
char *foot = sovereign_page_foot();
g_string_append(html, foot);
g_free(foot);
char *html_str = g_string_free(html, FALSE);
respond_html(request, html_str);

View File

@@ -27,7 +27,6 @@ static const char *NOSTR_SHIM_JS =
"\n"
" function bridgeCall(method, bodyObj) {\n"
" var url = BRIDGE_BASE + method;\n"
" var opts = {};\n"
"\n"
" if (bodyObj !== undefined && bodyObj !== null) {\n"
" /* Encode the body as a query parameter since WebKitGTK's custom\n"
@@ -36,20 +35,37 @@ static const char *NOSTR_SHIM_JS =
" url += '?body=' + encodeURIComponent(bodyJson);\n"
" }\n"
"\n"
" return fetch(url).then(function(resp) {\n"
" return resp.text();\n"
" }).then(function(text) {\n"
" var data;\n"
" /* Use synchronous XMLHttpRequest — WebKitGTK's fetch() and async XHR\n"
" * enforce CORS on custom schemes even when registered as secure, but\n"
" * synchronous XHR to a secure custom scheme works without CORS headers.\n"
" * The bridge is local (sovereign:// URI scheme handler in C), so the\n"
" * call is effectively instant. */\n"
" return new Promise(function(resolve, reject) {\n"
" try {\n"
" data = JSON.parse(text);\n"
" var xhr = new XMLHttpRequest();\n"
" xhr.open('GET', url, false); /* synchronous */\n"
" xhr.send();\n"
" var text = xhr.responseText;\n"
" if (!text) {\n"
" reject(new Error('Bridge returned empty response'));\n"
" return;\n"
" }\n"
" var data;\n"
" try {\n"
" data = JSON.parse(text);\n"
" } catch(e) {\n"
" reject(new Error('Bridge returned invalid JSON: ' + text));\n"
" return;\n"
" }\n"
" if (data.error !== undefined) {\n"
" var msg = data.message || ('Error code ' + data.error);\n"
" reject(new Error(msg));\n"
" return;\n"
" }\n"
" resolve(data);\n"
" } catch(e) {\n"
" throw new Error('Bridge returned invalid JSON: ' + text);\n"
" reject(new Error('Bridge request failed: ' + e.message));\n"
" }\n"
" if (data.error !== undefined) {\n"
" var msg = data.message || ('Error code ' + data.error);\n"
" throw new Error(msg);\n"
" }\n"
" return data;\n"
" });\n"
" }\n"
"\n"

View File

@@ -40,6 +40,11 @@ static void settings_set_defaults(browser_settings_t *s) {
SETTINGS_BOOTSTRAP_RELAYS_DEFAULT);
snprintf(s->search_engine, sizeof(s->search_engine), "%s",
SETTINGS_SEARCH_ENGINE_DEFAULT);
s->theme_dark = TRUE; /* default: dark mode */
s->inspector_x = -1; /* -1 = let window manager decide */
s->inspector_y = -1;
s->inspector_w = -1;
s->inspector_h = -1;
}
/* ── Parsing helpers ──────────────────────────────────────────────── */
@@ -150,6 +155,18 @@ void settings_load(void) {
val = db_kv_get("search_engine");
if (val) snprintf(g_settings.search_engine, sizeof(g_settings.search_engine), "%s", val);
val = db_kv_get("theme_dark");
if (val) g_settings.theme_dark = parse_bool(val, g_settings.theme_dark);
val = db_kv_get("inspector_x");
if (val) g_settings.inspector_x = parse_int(val, g_settings.inspector_x);
val = db_kv_get("inspector_y");
if (val) g_settings.inspector_y = parse_int(val, g_settings.inspector_y);
val = db_kv_get("inspector_w");
if (val) g_settings.inspector_w = parse_int(val, g_settings.inspector_w);
val = db_kv_get("inspector_h");
if (val) g_settings.inspector_h = parse_int(val, g_settings.inspector_h);
}
void settings_save(void) {
@@ -180,6 +197,16 @@ void settings_save(void) {
db_kv_set("bootstrap_relays", g_settings.bootstrap_relays);
db_kv_set("search_engine", g_settings.search_engine);
db_kv_set("theme_dark", g_settings.theme_dark ? "true" : "false");
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_x);
db_kv_set("inspector_x", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_y);
db_kv_set("inspector_y", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_w);
db_kv_set("inspector_w", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_h);
db_kv_set("inspector_h", buf);
}
const browser_settings_t *settings_get(void) {

View File

@@ -44,6 +44,11 @@ typedef struct {
int agent_login_timeout_ms; /* wait for agent login before GTK dialog */
char bootstrap_relays[SETTINGS_BOOTSTRAP_RELAYS_MAX]; /* newline-separated relay URLs */
char search_engine[SETTINGS_SEARCH_ENGINE_MAX]; /* active search engine id */
gboolean theme_dark; /* dark mode for sovereign:// pages */
int inspector_x; /* inspector detached window X (-1 = default) */
int inspector_y; /* inspector detached window Y (-1 = default) */
int inspector_w; /* inspector detached window width (-1 = default) */
int inspector_h; /* inspector detached window height (-1 = default) */
} browser_settings_t;
/*

View File

@@ -77,12 +77,16 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
[SHORTCUT_NEW_IDENTITY] = {
"new_identity", "Switch identity",
"Open the Nostr login dialog to switch identity",
"<Control><Shift>i"
"<Control><Shift>u"
},
[SHORTCUT_TOGGLE_FULLSCREEN] = {
"toggle_fullscreen", "Toggle fullscreen",
"Enter or exit fullscreen mode", "F11"
},
[SHORTCUT_TOGGLE_INSPECTOR] = {
"toggle_inspector", "Toggle inspector",
"Show or hide the WebKit Web Inspector", "<Control><Shift>i"
},
};
/* ── In-memory parsed bindings ──────────────────────────────────────── */
@@ -155,9 +159,14 @@ int shortcuts_lookup(GdkEventKey *event) {
guint event_mods = event->state & gtk_accelerator_get_default_mod_mask();
/* gtk_accelerator_parse() stores lowercase keyvals for letter keys,
* but GdkEventKey delivers the uppercase keyval when Shift is held.
* Normalize the event keyval to lowercase for comparison. */
guint event_keyval = gdk_keyval_to_lower(event->keyval);
for (int i = 0; i < SHORTCUT_COUNT; i++) {
if (g_parsed[i].keyval == 0) continue;
if (g_parsed[i].keyval == event->keyval &&
if (g_parsed[i].keyval == event_keyval &&
g_parsed[i].mods == event_mods) {
return i;
}

View File

@@ -42,6 +42,7 @@ typedef enum {
SHORTCUT_OPEN_SETTINGS,
SHORTCUT_NEW_IDENTITY,
SHORTCUT_TOGGLE_FULLSCREEN,
SHORTCUT_TOGGLE_INSPECTOR,
SHORTCUT_COUNT
} shortcut_action_t;

View File

@@ -50,6 +50,11 @@ extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data);
extern void on_menu_settings(GtkMenuItem *item, gpointer data);
extern void on_menu_profile(GtkMenuItem *item, gpointer data);
/* Key press handler defined in main.c — connected to each webview so
* keyboard shortcuts are caught before WebKit consumes the event. */
extern gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data);
/* ── Static state ─────────────────────────────────────────────────── */
static GtkWidget *g_notebook = NULL;
@@ -1000,14 +1005,95 @@ static void on_menu_stop(GtkMenuItem *item, gpointer data) {
}
}
/* ── Inspector window position/size persistence ────────────────────── *
* When the inspector detaches into its own window, we track the window's
* position and size via configure-event and save to settings. On the
* next show, we restore the saved geometry. */
static gboolean on_inspector_window_configure(GtkWidget *widget,
GdkEventConfigure *event,
gpointer user_data) {
(void)widget;
(void)user_data;
browser_settings_t *bs = settings_get_mutable();
bs->inspector_x = event->x;
bs->inspector_y = event->y;
bs->inspector_w = event->width;
bs->inspector_h = event->height;
settings_save();
return FALSE;
}
/* Find the detached inspector's toplevel GtkWindow and hook configure-event.
* Called when the inspector's WebView is realized (after detach). */
static void on_inspector_webview_realize(GtkWidget *widget,
gpointer user_data) {
(void)user_data;
GtkWidget *toplevel = gtk_widget_get_toplevel(widget);
if (toplevel && GTK_IS_WINDOW(toplevel)) {
/* Restore saved geometry if we have it. */
const browser_settings_t *bs = settings_get();
if (bs->inspector_w > 0 && bs->inspector_h > 0) {
gtk_window_resize(GTK_WINDOW(toplevel),
bs->inspector_w, bs->inspector_h);
}
if (bs->inspector_x >= 0 && bs->inspector_y >= 0) {
gtk_window_move(GTK_WINDOW(toplevel),
bs->inspector_x, bs->inspector_y);
}
/* Track future changes. */
g_signal_connect(toplevel, "configure-event",
G_CALLBACK(on_inspector_window_configure), NULL);
}
}
static void on_inspector_detach(WebKitWebInspector *inspector,
gpointer user_data) {
(void)user_data;
WebKitWebViewBase *insp_view = webkit_web_inspector_get_web_view(inspector);
if (insp_view) {
GtkWidget *w = GTK_WIDGET(insp_view);
/* Hook realize to catch the detached window. */
g_signal_connect(w, "realize",
G_CALLBACK(on_inspector_webview_realize), NULL);
}
}
/* Track whether the inspector is currently shown for the active tab.
* We use a per-tab flag stored in a static hash (inspector state is
* per-webview, but we track it globally for the toggle). */
static gboolean g_inspector_visible = FALSE;
void tab_manager_toggle_inspector(void) {
tab_info_t *tab = tab_manager_get_active();
if (!tab || !tab->webview) return;
WebKitWebInspector *insp = webkit_web_view_get_inspector(tab->webview);
if (!insp) return;
/* Connect signals once (idempotent — WebKitWebInspector is per-webview
* and persists for the webview's lifetime). We check a g_object data
* flag to avoid connecting multiple times. */
if (g_object_get_data(G_OBJECT(insp), "sovereign-inspector-hooked") == NULL) {
g_signal_connect(insp, "detach",
G_CALLBACK(on_inspector_detach), NULL);
g_object_set_data(G_OBJECT(insp), "sovereign-inspector-hooked",
GINT_TO_POINTER(1));
}
if (g_inspector_visible) {
webkit_web_inspector_close(insp);
g_inspector_visible = FALSE;
} else {
webkit_web_inspector_show(insp);
g_inspector_visible = TRUE;
}
}
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
WebKitWebInspector *insp = webkit_web_view_get_inspector(tab->webview);
if (insp) webkit_web_inspector_show(insp);
}
tab_manager_toggle_inspector();
}
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
@@ -1504,6 +1590,14 @@ static tab_info_t *tab_create(const char *url) {
/* Inject window.nostr into this webview. */
nostr_inject_setup(tab->webview);
/* Connect the key-press handler to the webview so browser-level
* shortcuts (Ctrl+T, Ctrl+Shift+I, etc.) are caught even when the
* webview has focus. We connect AFTER so the webview still gets
* normal key events for text input, but our handler can intercept
* recognized shortcuts. */
g_signal_connect(G_OBJECT(tab->webview), "key-press-event",
G_CALLBACK(on_key_press), NULL);
/* Build the per-tab page: toolbar on top, webview below. */
tab->page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

View File

@@ -152,6 +152,14 @@ void tab_manager_apply_settings(void);
*/
void tab_manager_set_avatar(const char *pubkey_hex);
/*
* Toggle the WebKit Web Inspector for the active tab.
* If the inspector is visible, it is closed; otherwise it is shown.
* The inspector's detached window position and size are saved to
* settings and restored on the next show.
*/
void tab_manager_toggle_inspector(void);
#ifdef __cplusplus
}
#endif

View File

@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.15"
#define SB_VERSION "v0.0.16"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 15
#define SB_VERSION_PATCH 16
#endif /* SOVEREIGN_BROWSER_VERSION_H */

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCP Button Test Page</title>
<style>
body { font-family: monospace; max-width: 600px; margin: 40px auto; padding: 20px; }
button { display: block; margin: 10px 0; padding: 10px 20px; font-size: 16px; cursor: pointer; }
#result { margin-top: 20px; padding: 10px; border: 1px solid #ccc; min-height: 40px; }
.hidden { display: none; }
</style>
</head>
<body>
<h1>Button Test Page</h1>
<button id="btn-simple" onclick="document.getElementById('result').textContent='Simple button clicked!'">Click Me</button>
<button id="btn-addclass" class="min-h-[60vh] inline-flex items-center" onclick="document.getElementById('result').textContent='Tailwind button clicked!'">Tailwind Button</button>
<button id="btn-react-like" data-testid="react-btn">React-like Button</button>
<div id="result">No button clicked yet.</div>
<div id="hidden-section" class="hidden">
<p>This section appears after clicking the toggle button.</p>
<button id="btn-in-hidden" onclick="alert('Hidden button clicked!')">Hidden Button</button>
</div>
<button id="btn-toggle" onclick="var s=document.getElementById('hidden-section'); s.classList.toggle('hidden'); document.getElementById('result').textContent='Toggled hidden section'">Toggle Hidden Section</button>
<script>
// React-like event handling (addEventListener, not onclick)
document.getElementById('btn-react-like').addEventListener('click', function() {
document.getElementById('result').textContent = 'React-like button clicked via addEventListener!';
});
// Pointer event handling (like Radix UI)
var btnPointer = document.createElement('button');
btnPointer.id = 'btn-pointer';
btnPointer.textContent = 'Pointer Event Button';
btnPointer.addEventListener('pointerdown', function(e) {
document.getElementById('result').textContent = 'Pointer down detected!';
});
btnPointer.addEventListener('click', function(e) {
document.getElementById('result').textContent = 'Pointer button clicked!';
});
document.body.insertBefore(btnPointer, document.getElementById('result'));
</script>
</body>
</html>

91
tests/test_mcp_forms.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCP Form Test Page</title>
<style>
body { font-family: monospace; max-width: 600px; margin: 40px auto; padding: 20px; }
label { display: block; margin: 10px 0 4px; font-weight: bold; }
input, textarea, select { display: block; width: 100%; max-width: 300px; padding: 8px; font-size: 14px; margin-bottom: 10px; }
button { padding: 10px 20px; font-size: 16px; cursor: pointer; margin: 5px 0; }
#output { margin-top: 20px; padding: 10px; border: 1px solid #ccc; min-height: 40px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>Form Test Page</h1>
<form id="test-form" onsubmit="event.preventDefault(); submitForm()">
<label for="name">Name:</label>
<input type="text" id="name" name="name" placeholder="Enter your name">
<label for="email">Email:</label>
<input type="email" id="email" name="email" placeholder="Enter your email">
<label for="message">Message:</label>
<textarea id="message" name="message" rows="3" placeholder="Enter a message"></textarea>
<label for="color">Favorite Color:</label>
<select id="color" name="color">
<option value="">Choose...</option>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
<label for="agree">
<input type="checkbox" id="agree" name="agree"> I agree to the terms
</label>
<button type="submit">Submit Form</button>
<button type="button" id="clear-btn">Clear</button>
</form>
<div id="output">Form not submitted yet.</div>
<script>
function submitForm() {
var data = {
name: document.getElementById('name').value,
email: document.getElementById('email').value,
message: document.getElementById('message').value,
color: document.getElementById('color').value,
agree: document.getElementById('agree').checked
};
document.getElementById('output').textContent = JSON.stringify(data, null, 2);
}
document.getElementById('clear-btn').addEventListener('click', function() {
document.getElementById('test-form').reset();
document.getElementById('output').textContent = 'Form cleared.';
});
// React-like controlled input simulation
var reactInput = document.createElement('input');
reactInput.type = 'text';
reactInput.id = 'react-input';
reactInput.placeholder = 'React-like controlled input';
reactInput.setAttribute('data-testid', 'react-input');
var reactLabel = document.createElement('label');
reactLabel.textContent = 'React-like Input:';
reactLabel.htmlFor = 'react-input';
var form = document.getElementById('test-form');
form.insertBefore(reactLabel, form.firstChild);
form.insertBefore(reactInput, reactLabel.nextSibling);
// Track value via event listener (like React does)
var reactValue = '';
reactInput.addEventListener('input', function(e) {
reactValue = e.target.value;
});
var reactBtn = document.createElement('button');
reactBtn.type = 'button';
reactBtn.textContent = 'Show React Value';
reactBtn.addEventListener('click', function() {
document.getElementById('output').textContent = 'React input value: ' + reactValue;
});
form.appendChild(reactBtn);
</script>
</body>
</html>