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:
78
plans/mcp-improvements.md
Normal file
78
plans/mcp-improvements.md
Normal 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
|
||||
@@ -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\"]}"},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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\";"
|
||||
|
||||
15
src/main.c
15
src/main.c
@@ -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");
|
||||
|
||||
|
||||
@@ -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 ☀☾</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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ typedef enum {
|
||||
SHORTCUT_OPEN_SETTINGS,
|
||||
SHORTCUT_NEW_IDENTITY,
|
||||
SHORTCUT_TOGGLE_FULLSCREEN,
|
||||
SHORTCUT_TOGGLE_INSPECTOR,
|
||||
SHORTCUT_COUNT
|
||||
} shortcut_action_t;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
51
tests/test_mcp_buttons.html
Normal file
51
tests/test_mcp_buttons.html
Normal 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
91
tests/test_mcp_forms.html
Normal 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>
|
||||
Reference in New Issue
Block a user