Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc5ec7771 | ||
|
|
992b09357a | ||
|
|
24d07946b9 | ||
|
|
e0607fe045 | ||
|
|
ae43decd4d |
+31
-5
@@ -40,7 +40,9 @@ cleanup_on_exit() {
|
||||
}
|
||||
trap cleanup_on_exit EXIT INT TERM
|
||||
register_cleanup() {
|
||||
[[ -n "$1" ]] && CLEANUP_FILES+=("$1")
|
||||
# Guard so an empty argument (e.g. a failed tarball path) does not cause
|
||||
# this function to return non-zero and abort the script under `set -e`.
|
||||
[[ -n "$1" ]] && CLEANUP_FILES+=("$1") || true
|
||||
}
|
||||
|
||||
# --- Config -------------------------------------------------------------
|
||||
@@ -280,7 +282,19 @@ build_release_binary() {
|
||||
create_source_tarball() {
|
||||
local tarball_name="${BIN_NAME}-${NEW_VERSION#v}.tar.gz"
|
||||
|
||||
if tar -czf "$tarball_name" \
|
||||
# Write the tarball to a temp path OUTSIDE the archived tree (.) so that
|
||||
# creating it does not change the directory while tar is reading it
|
||||
# (which makes tar emit "file changed as we read it" and exit 1, which
|
||||
# under `set -e` would abort the whole release flow). Move it into place
|
||||
# only after tar finishes.
|
||||
local tmp_tarball
|
||||
tmp_tarball=$(mktemp -t "${tarball_name}.XXXXXX") || return 1
|
||||
|
||||
# tar exits 1 for "file changed as we read it", which is benign here
|
||||
# because the only thing changing is the tarball we are writing (now in
|
||||
# /tmp). Accept exit codes 0 and 1 as success; anything else is fatal.
|
||||
set +e
|
||||
tar -czf "$tmp_tarball" \
|
||||
--exclude="$BIN_NAME" \
|
||||
--exclude='.git*' \
|
||||
--exclude='*.log' \
|
||||
@@ -290,11 +304,23 @@ create_source_tarball() {
|
||||
--exclude='tests/local-site/assets/*.m4a' \
|
||||
--exclude='tests/local-site/assets/*.webm' \
|
||||
--exclude='tests/local-site/assets/*.ogg' \
|
||||
. > /dev/null 2>&1; then
|
||||
echo "$tarball_name"
|
||||
else
|
||||
. > /dev/null 2>&1
|
||||
local tar_rc=$?
|
||||
set -e
|
||||
|
||||
if [[ $tar_rc -ne 0 && $tar_rc -ne 1 ]]; then
|
||||
rm -f "$tmp_tarball"
|
||||
print_error "tar failed with exit code $tar_rc"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! mv "$tmp_tarball" "$tarball_name" 2>/dev/null; then
|
||||
rm -f "$tmp_tarball"
|
||||
print_error "Failed to move tarball into place: $tarball_name"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$tarball_name"
|
||||
}
|
||||
|
||||
create_gitea_release() {
|
||||
|
||||
+2
-2
@@ -230,8 +230,8 @@ const mcp_tool_def_t tool_defs[] = {
|
||||
"{\"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\"]}"},
|
||||
"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. Optional 'button': 1=left (default), 2=middle, 3=right (opens the native context menu).",
|
||||
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"},\"button\":{\"type\":\"integer\",\"enum\":[1,2,3],\"default\":1}},\"required\":[\"x\",\"y\"]}"},
|
||||
|
||||
{"fill",
|
||||
"Clear an input and fill it with a value.",
|
||||
|
||||
+32
-12
@@ -12,10 +12,31 @@
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ── Synchronous JS evaluation ─────────────────────────────────────── *
|
||||
* WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop
|
||||
* on the default context to wait for the result. The key is to acquire
|
||||
* the context before creating the loop so that the WebKit IPC sources
|
||||
* are properly dispatched.
|
||||
* WebKitGTK's evaluate_javascript is async. We run a nested GMainLoop
|
||||
* on the default main context until the JS callback (or a timeout)
|
||||
* quits it.
|
||||
*
|
||||
* This function is routinely called from within a Soup server source
|
||||
* callback (the MCP HTTP handler) that is already dispatching on the
|
||||
* default main context. Running a nested GMainLoop on the same context
|
||||
* is the standard GTK modal pattern (gtk_dialog_run does the same) and
|
||||
* is safe: g_main_loop_run() manages context ownership internally for
|
||||
* the nested run, and the outer loop's dispatch is suspended until the
|
||||
* inner loop quits.
|
||||
*
|
||||
* The PREVIOUS code additionally called g_main_context_acquire()/
|
||||
* g_main_context_release() around the loop. That was the bug: the
|
||||
* default context is already owned by the outer dispatch, so acquire()
|
||||
* fails silently (owner_count is NOT incremented), yet release() then
|
||||
* decrements it unconditionally — producing
|
||||
* "g_main_context_release_unlocked: assertion 'context->owner_count > 0'"
|
||||
* and, combined with the recursive check, a segfault. The fix is simply
|
||||
* to drop the explicit acquire/release and let g_main_loop_run() handle
|
||||
* ownership. We must NOT use a g_main_context_iteration() pump instead,
|
||||
* because that would re-dispatch unrelated ready sources (other Soup
|
||||
* requests, WebKit load events, GTK UI events) re-entrantly while a
|
||||
* tool call is in progress, which can deadlock when an MCP tool runs
|
||||
* during page loading.
|
||||
*/
|
||||
|
||||
typedef struct {
|
||||
@@ -48,6 +69,7 @@ static gboolean on_js_timeout(gpointer user_data) {
|
||||
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
|
||||
if (!ctx->done) {
|
||||
g_printerr("[agent] JS evaluation timed out\n");
|
||||
ctx->done = TRUE;
|
||||
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
@@ -61,26 +83,24 @@ char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
|
||||
|
||||
js_eval_ctx_t ctx = {0};
|
||||
|
||||
/* Use the default main context for the nested loop. */
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
/* Nested loop on the default context. Do NOT acquire/release the
|
||||
* context here — see the block comment above. */
|
||||
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
|
||||
|
||||
/* Add a timeout source to the same context. */
|
||||
guint timeout_id = g_timeout_add(timeout_ms, on_js_timeout, &ctx);
|
||||
|
||||
/* Start async evaluation. */
|
||||
/* Start async evaluation. The completion callback fires on the
|
||||
* default main context (where WebKit schedules its async work). */
|
||||
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
|
||||
on_js_finished, &ctx);
|
||||
|
||||
/* Run the nested loop until JS completes or timeout. */
|
||||
/* Run the nested loop until JS completes or the timeout quits it. */
|
||||
g_main_loop_run(ctx.loop);
|
||||
|
||||
/* Cleanup. */
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
+43
-22
@@ -130,9 +130,12 @@ static WebKitWebView *get_active_webview(void) {
|
||||
*
|
||||
* 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) {
|
||||
* fills its parent window. `button` is 1 (left), 2 (middle), or 3 (right).
|
||||
* Returns TRUE on success. */
|
||||
static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y,
|
||||
int button) {
|
||||
if (wv == NULL) return FALSE;
|
||||
if (button < 1) button = 1;
|
||||
|
||||
GtkWidget *widget = GTK_WIDGET(wv);
|
||||
GdkWindow *window = gtk_widget_get_window(widget);
|
||||
@@ -150,7 +153,7 @@ static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
|
||||
press->button.y = y;
|
||||
press->button.axes = NULL;
|
||||
press->button.state = 0; /* no modifiers */
|
||||
press->button.button = 1; /* left button */
|
||||
press->button.button = 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);
|
||||
@@ -170,7 +173,7 @@ static gboolean synthesize_click_at(WebKitWebView *wv, double x, double y) {
|
||||
release->button.y = y;
|
||||
release->button.axes = NULL;
|
||||
release->button.state = 0;
|
||||
release->button.button = 1;
|
||||
release->button.button = button;
|
||||
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);
|
||||
@@ -1002,8 +1005,16 @@ static cJSON *tool_eval(cJSON *params) {
|
||||
/* ── Screenshot tool ──────────────────────────────────────────────── */
|
||||
|
||||
/* Context for the async webkit_web_view_snapshot() call. We use a
|
||||
* nested GMainLoop to turn the async API into a synchronous one,
|
||||
* matching the pattern in agent_js_eval_sync(). */
|
||||
* async API into a synchronous one.
|
||||
*
|
||||
* We run a nested GMainLoop on the default main context (the standard
|
||||
* GTK modal pattern) but do NOT call g_main_context_acquire()/
|
||||
* g_main_context_release() around it — the default context is already
|
||||
* owned by the outer dispatch (these tools are invoked from the MCP
|
||||
* HTTP handler), so an explicit acquire fails silently and a matching
|
||||
* release corrupts the owner_count, causing a segfault. The nested
|
||||
* g_main_loop_run() handles ownership internally. See the block comment
|
||||
* in agent_js_eval_sync() for the full rationale. */
|
||||
typedef struct {
|
||||
cairo_surface_t *surface;
|
||||
gboolean done;
|
||||
@@ -1031,6 +1042,7 @@ static gboolean snapshot_timeout_cb(gpointer user_data) {
|
||||
snapshot_ctx_t *ctx = (snapshot_ctx_t *)user_data;
|
||||
if (!ctx->done) {
|
||||
g_printerr("[agent] screenshot timed out\n");
|
||||
ctx->done = TRUE;
|
||||
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
|
||||
g_main_loop_quit(ctx->loop);
|
||||
}
|
||||
@@ -1051,11 +1063,10 @@ static cJSON *tool_screenshot(cJSON *params) {
|
||||
WebKitWebView *wv = get_active_webview();
|
||||
if (!wv) return make_error("NO_TAB", "No active tab");
|
||||
|
||||
/* Set up the nested GMainLoop to wait for the async snapshot. */
|
||||
/* Nested GMainLoop on the default context. No acquire/release —
|
||||
* see the note on snapshot_ctx_t above. */
|
||||
snapshot_ctx_t ctx = {0};
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
|
||||
|
||||
guint timeout_id = g_timeout_add(10000, snapshot_timeout_cb, &ctx);
|
||||
|
||||
@@ -1071,7 +1082,6 @@ static cJSON *tool_screenshot(cJSON *params) {
|
||||
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
if (!ctx.done || ctx.surface == NULL) {
|
||||
if (ctx.surface) cairo_surface_destroy(ctx.surface);
|
||||
@@ -1166,7 +1176,7 @@ static cJSON *tool_click(cJSON *params) {
|
||||
double cy = jy->valuedouble + jh->valuedouble / 2.0;
|
||||
cJSON_Delete(box);
|
||||
g_free(sel);
|
||||
if (synthesize_click_at(wv, cx, cy)) {
|
||||
if (synthesize_click_at(wv, cx, cy, 1)) {
|
||||
return make_success(NULL);
|
||||
}
|
||||
/* Fall through to JS fallback if GDK synthesis failed. */
|
||||
@@ -1210,7 +1220,17 @@ static cJSON *tool_click(cJSON *params) {
|
||||
/* 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. */
|
||||
* selector resolution entirely. Optional 'button': 1=left (default),
|
||||
* 2=middle, 3=right (triggers the native context menu). */
|
||||
static int click_button_from_params(cJSON *params) {
|
||||
cJSON *jb = cJSON_GetObjectItem(params, "button");
|
||||
if (jb && cJSON_IsNumber(jb)) {
|
||||
int b = jb->valueint;
|
||||
if (b >= 1 && b <= 3) return b;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static cJSON *tool_click_at(cJSON *params) {
|
||||
cJSON *jx = cJSON_GetObjectItem(params, "x");
|
||||
cJSON *jy = cJSON_GetObjectItem(params, "y");
|
||||
@@ -1223,8 +1243,9 @@ static cJSON *tool_click_at(cJSON *params) {
|
||||
|
||||
double x = jx->valuedouble;
|
||||
double y = jy->valuedouble;
|
||||
int button = click_button_from_params(params);
|
||||
|
||||
if (!synthesize_click_at(wv, x, y)) {
|
||||
if (!synthesize_click_at(wv, x, y, button)) {
|
||||
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
|
||||
}
|
||||
return make_success(NULL);
|
||||
@@ -4022,11 +4043,9 @@ static cJSON *tool_screenshot_annotated(cJSON *params) {
|
||||
/* We don't strictly need the result, but wait for it to complete. */
|
||||
g_free(ann_result);
|
||||
|
||||
/* Step 3: Take the WebKit snapshot (same pattern as tool_screenshot). */
|
||||
/* Step 3: Take the WebKit snapshot (same nested-loop pattern as tool_screenshot). */
|
||||
snapshot_ctx_t ctx = {0};
|
||||
GMainContext *main_ctx = g_main_context_default();
|
||||
g_main_context_acquire(main_ctx);
|
||||
ctx.loop = g_main_loop_new(main_ctx, FALSE);
|
||||
ctx.loop = g_main_loop_new(g_main_context_default(), FALSE);
|
||||
|
||||
guint timeout_id = g_timeout_add(10000, snapshot_timeout_cb, &ctx);
|
||||
|
||||
@@ -4039,7 +4058,6 @@ static cJSON *tool_screenshot_annotated(cJSON *params) {
|
||||
|
||||
g_source_remove(timeout_id);
|
||||
g_main_loop_unref(ctx.loop);
|
||||
g_main_context_release(main_ctx);
|
||||
|
||||
if (!ctx.done || ctx.surface == NULL) {
|
||||
if (ctx.surface) cairo_surface_destroy(ctx.surface);
|
||||
@@ -4581,7 +4599,7 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
double cy = jy->valuedouble + jh->valuedouble / 2.0;
|
||||
cJSON_Delete(box);
|
||||
g_free(box_result);
|
||||
if (synthesize_click_at(wv, cx, cy)) {
|
||||
if (synthesize_click_at(wv, cx, cy, 1)) {
|
||||
g_free(sel);
|
||||
/* Send a success response directly through the
|
||||
* WebSocket connection, matching the async path. */
|
||||
@@ -4619,7 +4637,9 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
|
||||
/* 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. */
|
||||
* GDK button press/release. Optional 'button': 1=left (default),
|
||||
* 2=middle, 3=right (triggers the native context menu). 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");
|
||||
@@ -4630,7 +4650,8 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
|
||||
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)) {
|
||||
int button = click_button_from_params(params);
|
||||
if (!synthesize_click_at(wv, x, y, button)) {
|
||||
return make_error("CLICK_FAILED", "Failed to synthesize GDK click event");
|
||||
}
|
||||
/* Send success response directly through the WebSocket. */
|
||||
|
||||
+45
-42
@@ -8,12 +8,13 @@
|
||||
* - d tag = HMAC-SHA256(hmac_key, path) (deterministic, opaque, 64 hex)
|
||||
* - content = NIP-44 encrypted JSON {"path": ..., "bookmarks": [...]}
|
||||
* - hmac_key = HMAC-SHA256(privkey, BOOKMARKS_HMAC_KEY_LABEL)
|
||||
*
|
||||
* On startup, the relay fetch retrieves kind 30003 events, which are
|
||||
* decrypted and loaded into an in-memory trie. Legacy events with plaintext
|
||||
* d tags (from the previous flat-directory implementation) are detected,
|
||||
* migrated to the new HMAC-d format, and their old events are marked for
|
||||
* kind 5 deletion.
|
||||
* d tags (from the previous flat-directory implementation) are IGNORED on
|
||||
* load — they are neither migrated nor re-published. This project is
|
||||
* pre-public and the only user has already re-published every folder with
|
||||
* HMAC d tags, so legacy events are dead data. Skipping them avoids the
|
||||
* per-boot migration/publish storm that earlier versions triggered.
|
||||
*/
|
||||
|
||||
#include "bookmarks.h"
|
||||
@@ -541,6 +542,8 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
if (events != NULL) {
|
||||
int n = cJSON_GetArraySize(events);
|
||||
g_print("[bookmarks] Loading %d cached folder event(s)\n", n);
|
||||
int legacy_skipped = 0;
|
||||
int loaded = 0;
|
||||
cJSON *event;
|
||||
cJSON_ArrayForEach(event, events) {
|
||||
/* Get the d tag. */
|
||||
@@ -560,6 +563,25 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Ignore legacy (non-HMAC) bookmark events entirely.
|
||||
* Legacy events used the plaintext folder path as the d tag;
|
||||
* the current format uses an opaque HMAC-SHA256 d tag (see
|
||||
* path_to_d_tag). This project is pre-public and the only
|
||||
* user has already re-published every folder with HMAC d
|
||||
* tags, so legacy events are dead data. Skipping them here
|
||||
* (a) avoids the per-boot migration/publish storm that
|
||||
* re-encrypted and re-published every folder on every launch,
|
||||
* and (b) keeps the local SQLite cache from re-triggering
|
||||
* migration via the duplicate legacy rows that earlier
|
||||
* migrations left behind (publish_deletion_for_d stored a
|
||||
* kind 5 tombstone but never deleted the old kind 30003
|
||||
* row). Legacy events are simply not loaded; they are not
|
||||
* deleted from the db or from relays here. */
|
||||
if (!is_hmac_d_tag(d_value)) {
|
||||
legacy_skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Decrypt content. */
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (!cJSON_IsString(content) || content->valuestring[0] == '\0')
|
||||
@@ -568,22 +590,14 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
char *plaintext = decrypt_content(content->valuestring);
|
||||
if (plaintext == NULL) continue;
|
||||
|
||||
/* Determine the path. */
|
||||
/* New format: path is inside the encrypted content. */
|
||||
char *path_from_content = NULL;
|
||||
bookmark_node_t scratch = {0};
|
||||
scratch.name = g_strdup("");
|
||||
scratch.path = g_strdup("");
|
||||
node_load_from_content(&scratch, plaintext, &path_from_content);
|
||||
|
||||
const char *path = NULL;
|
||||
if (is_hmac_d_tag(d_value)) {
|
||||
/* New format: path is inside the encrypted content. */
|
||||
path = path_from_content;
|
||||
} else {
|
||||
/* Legacy: d tag is the plaintext path. */
|
||||
path = d_value;
|
||||
}
|
||||
|
||||
const char *path = path_from_content;
|
||||
if (path == NULL || path[0] == '\0') path = "General";
|
||||
|
||||
bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL);
|
||||
@@ -595,21 +609,7 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
scratch.bookmarks = NULL;
|
||||
scratch.bookmark_count = 0;
|
||||
}
|
||||
|
||||
/* Migration: re-publish with the current single-step HMAC d
|
||||
* tag and delete the old event if the d tag doesn't match.
|
||||
* This covers both legacy plaintext d tags AND old two-step
|
||||
* HMAC d tags (from before the single-step switch). */
|
||||
if (g_have_signer && path != NULL && path[0] != '\0') {
|
||||
char *new_d = path_to_d_tag(path);
|
||||
if (new_d != NULL && strcmp(new_d, d_value) != 0) {
|
||||
g_print("[bookmarks] Migrating folder '%s' to current HMAC d tag\n",
|
||||
path);
|
||||
if (leaf) publish_node(leaf);
|
||||
publish_deletion_for_d(d_value);
|
||||
}
|
||||
g_free(new_d);
|
||||
}
|
||||
loaded++;
|
||||
|
||||
g_free(path_from_content);
|
||||
/* node_free frees scratch.name, scratch.path, and any
|
||||
@@ -618,6 +618,11 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
|
||||
free(plaintext);
|
||||
}
|
||||
cJSON_Delete(events);
|
||||
if (legacy_skipped > 0) {
|
||||
g_print("[bookmarks] Skipped %d legacy (non-HMAC) folder event(s)\n",
|
||||
legacy_skipped);
|
||||
}
|
||||
g_print("[bookmarks] Loaded %d HMAC folder event(s)\n", loaded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,6 +1060,16 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Ignore legacy (non-HMAC) bookmark events. See the matching comment in
|
||||
* bookmarks_init() for the full rationale. The event has already been
|
||||
* stored in SQLite by the caller (relay_fetch); we simply do not load
|
||||
* it into the in-memory tree and do not migrate/re-publish it. This
|
||||
* breaks the per-boot migration storm that was triggered every time the
|
||||
* relay fetch re-served a legacy event. */
|
||||
if (!is_hmac_d_tag(d_value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
|
||||
if (!cJSON_IsString(content) || content->valuestring[0] == '\0')
|
||||
return 0;
|
||||
@@ -1068,7 +1083,7 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
scratch.path = g_strdup("");
|
||||
node_load_from_content(&scratch, plaintext, &path_from_content);
|
||||
|
||||
const char *path = is_hmac_d_tag(d_value) ? path_from_content : d_value;
|
||||
const char *path = path_from_content;
|
||||
if (path == NULL || path[0] == '\0') path = "General";
|
||||
|
||||
bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL);
|
||||
@@ -1080,18 +1095,6 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
|
||||
scratch.bookmark_count = 0;
|
||||
}
|
||||
|
||||
/* Migration: re-publish with the current single-step HMAC d tag and
|
||||
* delete the old event if the d tag doesn't match. Covers both legacy
|
||||
* plaintext d tags and old two-step HMAC d tags. */
|
||||
if (g_have_signer && path != NULL && path[0] != '\0') {
|
||||
char *new_d = path_to_d_tag(path);
|
||||
if (new_d != NULL && strcmp(new_d, d_value) != 0) {
|
||||
if (leaf) publish_node(leaf);
|
||||
publish_deletion_for_d(d_value);
|
||||
}
|
||||
g_free(new_d);
|
||||
}
|
||||
|
||||
g_free(path_from_content);
|
||||
/* node_free frees scratch.name, scratch.path, and any remaining
|
||||
* bookmarks (already moved to leaf, so NULL). */
|
||||
|
||||
@@ -129,6 +129,11 @@ static void settings_set_defaults(browser_settings_t *s) {
|
||||
|
||||
s->offline_site_max_pages = SETTINGS_OFFLINE_SITE_MAX_PAGES_DEFAULT;
|
||||
s->offline_site_request_delay_ms = SETTINGS_OFFLINE_SITE_REQUEST_DELAY_MS_DEFAULT;
|
||||
|
||||
/* Perf probe is OFF by default — its instrumentation overhead
|
||||
* (patched addEventListener/setTimeout/setInterval/XHR + a permanent
|
||||
* rAF loop) breaks scrolling on listener-heavy pages. */
|
||||
s->perf_probe_enabled = FALSE;
|
||||
}
|
||||
|
||||
/* ── Parsing helpers ──────────────────────────────────────────────── */
|
||||
@@ -331,6 +336,9 @@ void settings_load_user(void) {
|
||||
if (g_settings.offline_site_request_delay_ms < 0) g_settings.offline_site_request_delay_ms = 0;
|
||||
}
|
||||
|
||||
val = db_kv_get("perf_probe.enabled");
|
||||
if (val) g_settings.perf_probe_enabled = parse_bool(val, g_settings.perf_probe_enabled);
|
||||
|
||||
/* Agent LLM provider settings — these are the "resolved" values
|
||||
* (active provider's base_url + api_key + selected model). They are
|
||||
* populated from db_kv here for local persistence, and overwritten
|
||||
@@ -502,6 +510,8 @@ void settings_save_user(void) {
|
||||
snprintf(buf, sizeof(buf), "%d", g_settings.offline_site_request_delay_ms);
|
||||
db_kv_set("offline_site.request_delay_ms", buf);
|
||||
|
||||
db_kv_set("perf_probe.enabled", g_settings.perf_probe_enabled ? "true" : "false");
|
||||
|
||||
/* Agent LLM provider settings — resolved values (active provider). */
|
||||
db_kv_set("agent.llm_base_url", g_settings.agent_llm_base_url);
|
||||
db_kv_set("agent.llm_api_key", g_settings.agent_llm_api_key);
|
||||
|
||||
@@ -127,6 +127,17 @@ typedef struct {
|
||||
/* Offline website downloader (right-click tab → Download Website). */
|
||||
int offline_site_max_pages; /* safety cap on pages crawled */
|
||||
int offline_site_request_delay_ms; /* politeness delay between requests */
|
||||
|
||||
/* Per-tab performance probe (sovereign://processes). The probe patches
|
||||
* addEventListener / setTimeout / setInterval / XHR and runs a permanent
|
||||
* requestAnimationFrame loop on every page, which adds main-thread
|
||||
* overhead. On listener-heavy pages (e.g. NIP-17 DM clients that
|
||||
* re-register many listeners per re-render) this overhead is enough to
|
||||
* starve the main thread and break scrolling — the page's own
|
||||
* scroll-to-bottom rAF wins the race and yanks the view to the bottom.
|
||||
* Default OFF so normal browsing is not penalized; enable explicitly when
|
||||
* you want the processes/perf view. */
|
||||
gboolean perf_probe_enabled;
|
||||
} browser_settings_t;
|
||||
|
||||
/*
|
||||
|
||||
+40
-4
@@ -220,7 +220,12 @@ static const char *DL_RENDER_JS =
|
||||
" });"
|
||||
" el.setAttribute('srcset', out.join(', '));"
|
||||
" });"
|
||||
" /* Handle inline style url() and CSS. */"
|
||||
" /* Build the offline HTML from the rendered DOM. We remove all"
|
||||
" * <script> tags so the SPA framework doesn't re-execute and"
|
||||
" * overwrite our rewritten links with absolute URLs. The rendered"
|
||||
" * DOM is already correct (we saved it after JS rendered). */"
|
||||
" var scripts = document.querySelectorAll('script');"
|
||||
" scripts.forEach(function(s){ s.remove(); });"
|
||||
" var html = '<!DOCTYPE html>\\n' + document.documentElement.outerHTML;"
|
||||
" return JSON.stringify({ links: links, html: html });"
|
||||
"})()";
|
||||
@@ -1114,9 +1119,40 @@ static gpointer dl_worker(dl_state_t *st) {
|
||||
GPtrArray *rendered_links = dl_render_page(st, effective_url,
|
||||
&rendered_html);
|
||||
if (rendered_html) {
|
||||
/* Overwrite with the rendered version. */
|
||||
write_text_to_file(path, rendered_html,
|
||||
strlen(rendered_html));
|
||||
/* Strip any remaining <script> blocks that the JS
|
||||
* DOM removal missed (e.g. inline scripts added
|
||||
* dynamically). This prevents the SPA framework
|
||||
* from re-executing and overwriting our rewritten
|
||||
* links with absolute URLs. */
|
||||
{
|
||||
GString *stripped = g_string_new(NULL);
|
||||
const char *p = rendered_html;
|
||||
while (*p) {
|
||||
if (g_ascii_strncasecmp(p, "<script", 7) == 0) {
|
||||
/* Find the end of this script tag. */
|
||||
const char *end = NULL;
|
||||
/* Case-insensitive search for </script>. */
|
||||
for (const char *q = p; *q; q++) {
|
||||
if (g_ascii_strncasecmp(q, "</script>", 9) == 0) {
|
||||
end = q;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end) {
|
||||
p = end + 9; /* skip </script> */
|
||||
continue;
|
||||
} else {
|
||||
/* Unterminated script — skip to end. */
|
||||
break;
|
||||
}
|
||||
}
|
||||
g_string_append_c(stripped, *p);
|
||||
p++;
|
||||
}
|
||||
write_text_to_file(path, stripped->str,
|
||||
stripped->len);
|
||||
g_string_free(stripped, TRUE);
|
||||
}
|
||||
g_free(rendered_html);
|
||||
}
|
||||
if (rendered_links) {
|
||||
|
||||
+149
-15
@@ -137,9 +137,20 @@ static GtkWidget *g_target_notebook = NULL;
|
||||
* When non-NULL, tab_create() creates the webview with
|
||||
* webkit_web_view_new_with_related_view() so it shares the parent's
|
||||
* WebProcess and window features (avoids the WindowFeatures assertion
|
||||
* crash). Used by tab_manager_new_window(). */
|
||||
* crash). Used by tab_manager_new_window() and on_create_webview(). */
|
||||
static WebKitWebView *g_target_related_view = NULL;
|
||||
|
||||
/* When TRUE, tab_create() skips the explicit webkit_web_view_load_uri()
|
||||
* call. This is only needed in the "create" signal path (on_create_webview),
|
||||
* where WebKit already has a pending NavigationAction with WindowFeatures
|
||||
* that it will apply to the returned webview itself — an explicit load
|
||||
* here would race with WebKit's own navigation setup and trigger the
|
||||
* std::optional<WindowFeatures> assertion crash.
|
||||
*
|
||||
* tab_manager_new_window() does NOT set this: it creates a fresh webview
|
||||
* with no pending WebKit navigation, so it MUST load the URL explicitly. */
|
||||
static gboolean g_target_skip_load = FALSE;
|
||||
|
||||
/* Dynamic array of tab_info_t pointers, indexed by notebook page number. */
|
||||
static tab_info_t **g_tabs = NULL;
|
||||
static int g_tab_count = 0;
|
||||
@@ -1150,9 +1161,17 @@ static GtkWidget *on_create_webview(WebKitWebView *webview,
|
||||
if (uri && uri[0]) {
|
||||
/* Set the related view so tab_create() uses
|
||||
* webkit_web_view_new_with_related_view() — shares the parent's
|
||||
* WebProcess and avoids the WindowFeatures assertion crash. */
|
||||
* WebProcess and avoids the WindowFeatures assertion crash. Also
|
||||
* set g_target_skip_load: WebKit already has a pending
|
||||
* NavigationAction with WindowFeatures that it will apply to the
|
||||
* returned webview itself, so tab_create() must NOT call
|
||||
* webkit_web_view_load_uri() (that would race with WebKit's own
|
||||
* navigation setup and trigger the std::optional<WindowFeatures>
|
||||
* assertion crash). */
|
||||
g_target_related_view = webview;
|
||||
g_target_skip_load = TRUE;
|
||||
int idx = tab_manager_new_tab(uri);
|
||||
g_target_skip_load = FALSE;
|
||||
g_target_related_view = NULL;
|
||||
|
||||
if (idx >= 0) {
|
||||
@@ -1308,8 +1327,14 @@ static GtkWidget *tab_manager_new_window(const char *url,
|
||||
}
|
||||
|
||||
/* Inject the per-tab performance probe (sovereign://processes).
|
||||
* Skips internal sovereign:// pages at runtime via the preamble. */
|
||||
perf_probe_setup(tab->webview, index);
|
||||
* Skips internal sovereign:// pages at runtime via the preamble.
|
||||
* Gated on the perf_probe_enabled setting: the probe patches
|
||||
* addEventListener/setTimeout/setInterval/XHR and runs a permanent
|
||||
* rAF loop, which adds enough main-thread overhead to break
|
||||
* scrolling on listener-heavy pages. Default OFF. */
|
||||
if (settings_get()->perf_probe_enabled) {
|
||||
perf_probe_setup(tab->webview, index);
|
||||
}
|
||||
|
||||
const browser_settings_t *s = settings_get();
|
||||
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(notebook),
|
||||
@@ -1759,6 +1784,14 @@ static void on_title_changed(WebKitWebView *webview,
|
||||
|
||||
/* ── Web view context menu (right-click) ──────────────────────────── */
|
||||
|
||||
/* Context passed to on_context_open_in_new_window(): the link URL and the
|
||||
* webview that was right-clicked (used as the related view so the new
|
||||
* window shares the parent's WebProcess). */
|
||||
struct new_window_ctx {
|
||||
char *url;
|
||||
WebKitWebView *related_view;
|
||||
};
|
||||
|
||||
/* Callback for "Open Link in New Tab" / "Open Page in New Tab". */
|
||||
static void on_context_open_in_new_tab(GSimpleAction *action,
|
||||
GVariant *parameter,
|
||||
@@ -1772,6 +1805,47 @@ static void on_context_open_in_new_tab(GSimpleAction *action,
|
||||
g_free(url);
|
||||
}
|
||||
|
||||
/* Idle callback that actually creates the new window. We defer the
|
||||
* tab_manager_new_window() call out of the GAction "activate" handler
|
||||
* because that runs inside the GtkMenu's signal emission / GSource
|
||||
* teardown. Creating a new top-level window (which calls
|
||||
* gtk_widget_show_all + gtk_window_present and thus processes pending
|
||||
* events) from within that handler caused GLib-CRITICAL
|
||||
* g_source_ref/g_source_unref assertion failures as the menu's own
|
||||
* GSources were being released concurrently. Running on the next idle
|
||||
* tick lets the menu fully close first. */
|
||||
static gboolean on_idle_open_in_new_window(gpointer user_data) {
|
||||
struct new_window_ctx *ctx = (struct new_window_ctx *)user_data;
|
||||
if (ctx && ctx->url && ctx->url[0]) {
|
||||
tab_manager_new_window(ctx->url, ctx->related_view);
|
||||
}
|
||||
if (ctx) {
|
||||
g_free(ctx->url);
|
||||
g_slice_free(struct new_window_ctx, ctx);
|
||||
}
|
||||
return G_SOURCE_REMOVE; /* run once */
|
||||
}
|
||||
|
||||
/* Callback for "Open Link in New Window". Opens the link URL in a brand
|
||||
* new top-level GtkWindow (via tab_manager_new_window) instead of a new
|
||||
* tab. The related view (the webview that was right-clicked) is passed so
|
||||
* the new window shares the parent's WebProcess. The actual window
|
||||
* creation is deferred to an idle handler (see on_idle_open_in_new_window)
|
||||
* to avoid GLib-CRITICAL source-refcount warnings from creating the
|
||||
* window inside the menu's activate callback. */
|
||||
static void on_context_open_in_new_window(GSimpleAction *action,
|
||||
GVariant *parameter,
|
||||
gpointer user_data) {
|
||||
(void)action;
|
||||
(void)parameter;
|
||||
/* Steal the ctx and defer to idle — do NOT free it here; the idle
|
||||
* callback owns and frees it. */
|
||||
struct new_window_ctx *ctx = (struct new_window_ctx *)user_data;
|
||||
if (ctx) {
|
||||
g_idle_add(on_idle_open_in_new_window, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/* Context menu signal handler for the webview. Appends "Open in New Tab"
|
||||
* to the default WebKit context menu — either "Open Link in New Tab" if
|
||||
* right-clicking a link, or "Open Page in New Tab" if right-clicking the
|
||||
@@ -1821,21 +1895,63 @@ static gboolean on_webview_context_menu(WebKitWebView *webview,
|
||||
NULL);
|
||||
|
||||
/* For links: WebKit's default menu starts with "Open Link" (pos 0)
|
||||
* then "Open Link in New Window" (pos 1). Insert "Open Link in New
|
||||
* Tab" at position 1 — between "Open Link" and "Open Link in New
|
||||
* Window" — so the order is:
|
||||
* then "Open Link in New Window" (pos 1). We replace that default
|
||||
* "Open Link in New Window" item with our own that actually opens a
|
||||
* real top-level GtkWindow (WebKit's default fires the "create"
|
||||
* signal, which our on_create_webview handler routes to a new *tab*,
|
||||
* so without this replacement "Open in New Window" would wrongly open
|
||||
* a tab). We then insert "Open Link in New Tab" at position 1, so the
|
||||
* final order is:
|
||||
* Open Link
|
||||
* Open Link in New Tab
|
||||
* Open Link in New Window
|
||||
* Open Link in New Window (ours — real new window)
|
||||
*
|
||||
* For page background: insert at position 0 (top). */
|
||||
int insert_pos = is_link ? 1 : 0;
|
||||
* For page background: insert the "Open Page in New Tab" item at
|
||||
* position 0 (top). */
|
||||
int insert_pos;
|
||||
if (is_link) {
|
||||
/* Find and remove WebKit's stock "Open Link in New Window" item
|
||||
* so we can replace it with our own below. */
|
||||
guint n = webkit_context_menu_get_n_items(context_menu);
|
||||
for (guint i = 0; i < n; i++) {
|
||||
WebKitContextMenuItem *it =
|
||||
webkit_context_menu_get_item_at_position(context_menu, i);
|
||||
if (it == NULL) continue;
|
||||
if (webkit_context_menu_item_get_stock_action(it) ==
|
||||
WEBKIT_CONTEXT_MENU_ACTION_OPEN_LINK_IN_NEW_WINDOW) {
|
||||
webkit_context_menu_remove(context_menu, it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
insert_pos = 1;
|
||||
} else {
|
||||
insert_pos = 0;
|
||||
}
|
||||
webkit_context_menu_insert(context_menu, item, insert_pos);
|
||||
|
||||
/* The action is owned by the menu item now. g_object_unref is safe
|
||||
* because the WebKitContextMenuItem holds its own ref. */
|
||||
g_object_unref(action);
|
||||
|
||||
/* For links: also add our own "Open Link in New Window" item that
|
||||
* opens a real top-level GtkWindow. Insert it right after the
|
||||
* "Open Link in New Tab" item we just added (position 2). */
|
||||
if (is_link) {
|
||||
GSimpleAction *win_action =
|
||||
g_simple_action_new("open-in-new-window", NULL);
|
||||
struct new_window_ctx *ctx = g_slice_new(struct new_window_ctx);
|
||||
ctx->url = g_strdup(url);
|
||||
ctx->related_view = webview; /* share WebProcess with parent */
|
||||
g_signal_connect(win_action, "activate",
|
||||
G_CALLBACK(on_context_open_in_new_window), ctx);
|
||||
|
||||
WebKitContextMenuItem *win_item =
|
||||
webkit_context_menu_item_new_from_gaction(
|
||||
G_ACTION(win_action), "Open Link in New Window", NULL);
|
||||
webkit_context_menu_insert(context_menu, win_item, 2);
|
||||
g_object_unref(win_action);
|
||||
}
|
||||
|
||||
return FALSE; /* Let WebKit show the menu. */
|
||||
}
|
||||
|
||||
@@ -3029,7 +3145,7 @@ static tab_info_t *tab_create(const char *url) {
|
||||
* _M_get() const: Assertion 'this->_M_is_engaged()' failed.
|
||||
* So we skip the explicit load and let WebKit drive the navigation. The
|
||||
* tab's URL bar / title were already initialized from default_url above. */
|
||||
if (g_target_related_view == NULL) {
|
||||
if (!g_target_skip_load) {
|
||||
webkit_web_view_load_uri(tab->webview, default_url);
|
||||
}
|
||||
g_free(default_url);
|
||||
@@ -3582,8 +3698,11 @@ int tab_manager_new_tab(const char *url) {
|
||||
}
|
||||
|
||||
/* Inject the per-tab performance probe (sovereign://processes).
|
||||
* Skips internal sovereign:// pages at runtime via the preamble. */
|
||||
perf_probe_setup(tab->webview, index);
|
||||
* Skips internal sovereign:// pages at runtime via the preamble.
|
||||
* Gated on the perf_probe_enabled setting (see comment above). */
|
||||
if (settings_get()->perf_probe_enabled) {
|
||||
perf_probe_setup(tab->webview, index);
|
||||
}
|
||||
|
||||
/* Add to the effective notebook (active window's notebook, or the
|
||||
* main notebook as fallback). This makes Ctrl+T / "New Tab" open in
|
||||
@@ -3610,8 +3729,23 @@ int tab_manager_new_tab(const char *url) {
|
||||
gtk_widget_grab_focus(tab->url_entry);
|
||||
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
|
||||
|
||||
g_print("[tabs] Created tab %d, total: %d\n", page_num, g_tab_count);
|
||||
return page_num;
|
||||
g_print("[tabs] Created tab %d (page %d), total: %d\n",
|
||||
index, page_num, g_tab_count);
|
||||
|
||||
/* Return the g_tabs array index (NOT the notebook page_num).
|
||||
*
|
||||
* The notebook page number only matches the g_tabs index for the
|
||||
* main window; auxiliary windows have their own page numbering, and
|
||||
* even in the main window the two can diverge after tabs are closed
|
||||
* and reopened. The only caller that uses the return value
|
||||
* (on_create_webview, for the WebKit "create" signal) passes it to
|
||||
* tab_manager_get(), which expects a g_tabs index. Returning
|
||||
* page_num there caused tab_manager_get() to resolve the WRONG tab
|
||||
* — an already-existing webview with no pending WindowFeatures —
|
||||
* which WebKit then tried to apply WindowFeatures to, triggering:
|
||||
* std::optional<WindowFeatures>::_M_get(): Assertion
|
||||
* 'this->_M_is_engaged()' failed. */
|
||||
return index;
|
||||
}
|
||||
|
||||
void tab_manager_close_tab(int index) {
|
||||
|
||||
+2
-2
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.57"
|
||||
#define SB_VERSION "v0.0.62"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 57
|
||||
#define SB_VERSION_PATCH 62
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
+184
-1
@@ -9,6 +9,7 @@
|
||||
#include "profile.h"
|
||||
|
||||
#include <glib.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <webkit2/webkit2.h>
|
||||
|
||||
#include <string.h>
|
||||
@@ -18,6 +19,173 @@
|
||||
* wants the per-user context. */
|
||||
static WebKitWebContext *g_ctx = NULL;
|
||||
|
||||
/* ── Download handling ─────────────────────────────────────────────── *
|
||||
* WebKitGTK fires the "download-started" signal on the WebKitWebContext
|
||||
* whenever the user triggers a download — most notably the "Save Image"
|
||||
* context-menu item on an <img>. In the webkit2gtk-4.1 API the download
|
||||
* does NOT auto-prompt for a destination: the application must handle the
|
||||
* download's "decide-destination" signal (which supplies a suggested
|
||||
* filename), call webkit_download_set_destination(), and return TRUE.
|
||||
* If nobody handles "decide-destination", WebKit writes the file to
|
||||
* G_USER_DIRECTORY_DOWNLOAD using the suggested name — but on many
|
||||
* minimal/containerized setups that directory resolves to a path the
|
||||
* user never sees, so "Save Image" appears to do nothing.
|
||||
*
|
||||
* Our flow:
|
||||
* download-started (context) → take a ref, wire finished/failed
|
||||
* decide-destination (download) → show a GtkFileChooserDialog pre-filled
|
||||
* with the suggested filename, set the
|
||||
* chosen destination, return TRUE
|
||||
* finished / failed → unref the download
|
||||
*
|
||||
* Since 2.40, returning TRUE from "decide-destination" without
|
||||
* immediately calling webkit_download_set_destination() is allowed: the
|
||||
* download pauses until the destination is set or the download is
|
||||
* cancelled. We use that to run a modal file chooser inside the handler.
|
||||
*/
|
||||
|
||||
/* Find a reasonable parent GtkWindow for the file chooser. We prefer the
|
||||
* currently-active (focused) top-level window; if none is focused we fall
|
||||
* back to the first visible top-level. web_context.c deliberately does
|
||||
* not depend on tab_manager.c's g_active_window, so this stays self-
|
||||
* contained. Returns NULL if no usable window is found. */
|
||||
static GtkWindow *pick_dialog_parent(void) {
|
||||
GList *toplevels = gtk_window_list_toplevels();
|
||||
GtkWindow *active = NULL;
|
||||
GtkWindow *first_visible = NULL;
|
||||
|
||||
for (GList *l = toplevels; l != NULL; l = l->next) {
|
||||
GtkWindow *w = GTK_WINDOW(l->data);
|
||||
if (!gtk_widget_get_visible(GTK_WIDGET(w))) continue;
|
||||
if (first_visible == NULL) first_visible = w;
|
||||
if (gtk_window_is_active(w)) { active = w; break; }
|
||||
}
|
||||
|
||||
GtkWindow *result = active ? active : first_visible;
|
||||
if (result != NULL) g_object_ref_sink(G_OBJECT(result));
|
||||
g_list_free(toplevels);
|
||||
return result; /* caller unrefs */
|
||||
}
|
||||
|
||||
/* Reduce a possibly-full suggested path to its final path segment so the
|
||||
* save dialog never starts in an unexpected directory. Returns a newly
|
||||
* allocated string. */
|
||||
static char *basename_of(const char *name) {
|
||||
if (name == NULL || name[0] == '\0') return NULL;
|
||||
const char *slash = strrchr(name, '/');
|
||||
return g_strdup(slash ? slash + 1 : name);
|
||||
}
|
||||
|
||||
static void on_download_finished(WebKitDownload *dl, gpointer user) {
|
||||
(void)user;
|
||||
const char *dest = webkit_download_get_destination(dl);
|
||||
g_print("[download] Finished: %s\n", dest ? dest : "(no destination)");
|
||||
g_object_unref(dl);
|
||||
}
|
||||
|
||||
static void on_download_failed(WebKitDownload *dl, GError *err, gpointer user) {
|
||||
(void)user;
|
||||
g_printerr("[download] Failed: %s\n", err ? err->message : "(unknown)");
|
||||
g_object_unref(dl);
|
||||
}
|
||||
|
||||
/* "decide-destination" handler. WebKit passes the suggested filename
|
||||
* (derived from the response Content-Disposition / URL). We show a save
|
||||
* dialog, set the destination, and return TRUE. Returning TRUE without
|
||||
* setting a destination is explicitly supported since 2.40 and pauses
|
||||
* the download until we set one (or cancel). */
|
||||
|
||||
/* Remember the directory the user last saved a download to, so the next
|
||||
* save dialog opens in the same folder instead of the default. Owned by
|
||||
* this module (g_free'd on teardown). NULL until the first successful
|
||||
* save. */
|
||||
static char *g_last_save_dir = NULL;
|
||||
|
||||
static gboolean on_download_decide_destination(WebKitDownload *dl,
|
||||
const char *suggested,
|
||||
gpointer user) {
|
||||
(void)user;
|
||||
|
||||
char *default_name = basename_of(suggested);
|
||||
if (default_name == NULL || default_name[0] == '\0') {
|
||||
g_free(default_name);
|
||||
default_name = g_strdup("download");
|
||||
}
|
||||
|
||||
GtkWindow *parent = pick_dialog_parent();
|
||||
|
||||
GtkWidget *dialog = gtk_file_chooser_dialog_new(
|
||||
"Save File", parent, GTK_FILE_CHOOSER_ACTION_SAVE,
|
||||
"_Cancel", GTK_RESPONSE_CANCEL,
|
||||
"_Save", GTK_RESPONSE_ACCEPT,
|
||||
NULL);
|
||||
if (parent) g_object_unref(parent);
|
||||
|
||||
GtkFileChooser *chooser = GTK_FILE_CHOOSER(dialog);
|
||||
|
||||
/* If the user saved a download before, open the dialog in that same
|
||||
* directory. Otherwise GTK falls back to the current working
|
||||
* directory / last-used folder for the application. */
|
||||
if (g_last_save_dir != NULL && g_last_save_dir[0] != '\0') {
|
||||
gtk_file_chooser_set_current_folder(chooser, g_last_save_dir);
|
||||
}
|
||||
|
||||
gtk_file_chooser_set_current_name(chooser, default_name);
|
||||
gtk_file_chooser_set_do_overwrite_confirmation(chooser, TRUE);
|
||||
|
||||
gint resp = gtk_dialog_run(GTK_DIALOG(dialog));
|
||||
char *path = (resp == GTK_RESPONSE_ACCEPT)
|
||||
? gtk_file_chooser_get_filename(chooser)
|
||||
: NULL;
|
||||
gtk_widget_destroy(dialog);
|
||||
g_free(default_name);
|
||||
|
||||
if (path == NULL) {
|
||||
/* User cancelled — abort the download. */
|
||||
webkit_download_cancel(dl);
|
||||
return TRUE; /* we handled it (by cancelling) */
|
||||
}
|
||||
|
||||
/* Remember the directory the user chose for next time. */
|
||||
char *dir = g_path_get_dirname(path);
|
||||
if (dir != NULL && dir[0] != '\0' &&
|
||||
(g_last_save_dir == NULL ||
|
||||
g_strcmp0(dir, g_last_save_dir) != 0)) {
|
||||
g_free(g_last_save_dir);
|
||||
g_last_save_dir = g_strdup(dir);
|
||||
}
|
||||
g_free(dir);
|
||||
|
||||
char *uri = g_filename_to_uri(path, NULL, NULL);
|
||||
g_free(path);
|
||||
if (uri == NULL) {
|
||||
webkit_download_cancel(dl);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
webkit_download_set_destination(dl, uri);
|
||||
g_free(uri);
|
||||
return TRUE; /* stop other handlers; destination is now set */
|
||||
}
|
||||
|
||||
static void on_download_started(WebKitWebContext *ctx,
|
||||
WebKitDownload *dl,
|
||||
gpointer user_data) {
|
||||
(void)ctx;
|
||||
(void)user_data;
|
||||
|
||||
/* Hold a ref for the lifetime of the download so our finished/failed
|
||||
* handlers can safely access it; we unref there. */
|
||||
g_object_ref(dl);
|
||||
|
||||
g_signal_connect(dl, "decide-destination",
|
||||
G_CALLBACK(on_download_decide_destination), NULL);
|
||||
g_signal_connect(dl, "finished",
|
||||
G_CALLBACK(on_download_finished), NULL);
|
||||
g_signal_connect(dl, "failed",
|
||||
G_CALLBACK(on_download_failed), NULL);
|
||||
}
|
||||
|
||||
/* ── Security / TLS / favicon configuration ────────────────────────── *
|
||||
* Applied to every context we build (per-user and ephemeral). Mirrors
|
||||
* the configuration that main.c used to apply to the default context.
|
||||
@@ -27,12 +195,21 @@ static void configure_context(WebKitWebContext *ctx) {
|
||||
|
||||
/* Security strip: register sovereign://, tor://, file:// as secure
|
||||
* (no CORS enforcement), and file:// as local. Same as the original
|
||||
* main.c configuration. */
|
||||
* main.c configuration.
|
||||
*
|
||||
* Also register ws (insecure WebSocket) as secure. Without this,
|
||||
* WebKitGTK blocks ws:// connections opened from https:// pages as
|
||||
* mixed active content (a secure page may not connect to an
|
||||
* insecure origin). This browser is intentionally open and should
|
||||
* be able to connect to anywhere, including local plaintext
|
||||
* WebSocket services, so we treat ws:// as a secure scheme — the
|
||||
* same status wss:// already has by default. */
|
||||
WebKitSecurityManager *sec_mgr =
|
||||
webkit_web_context_get_security_manager(ctx);
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
|
||||
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "ws");
|
||||
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
|
||||
|
||||
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
|
||||
@@ -58,6 +235,12 @@ static void configure_context(WebKitWebContext *ctx) {
|
||||
g_get_tmp_dir());
|
||||
}
|
||||
webkit_web_context_set_favicon_database_directory(ctx, fav_dir);
|
||||
|
||||
/* Handle downloads — most importantly the "Save Image" context-menu
|
||||
* action. Without this handler WebKitGTK cancels the download (and
|
||||
* the user sees nothing happen). See on_download_started() above. */
|
||||
g_signal_connect(ctx, "download-started",
|
||||
G_CALLBACK(on_download_started), NULL);
|
||||
}
|
||||
|
||||
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex) {
|
||||
|
||||
Reference in New Issue
Block a user