1//go:build webkit_cgo
2
3package webkit
4
5/*
6#cgo pkg-config: webkitgtk-6.0 gtk4 javascriptcoregtk-6.0
7#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8#include <stdlib.h>
9#include <string.h>
10#include <gtk/gtk.h>
11#include <webkit/webkit.h>
12#include <glib-object.h>
13#include <gdk/gdk.h>
14#include <jsc/jsc.h>
15#include <glib.h>
16#include <gio/gio.h>
17#include "webview_callbacks.h"
18
19static GtkWidget* new_window() { return GTK_WIDGET(gtk_window_new()); }
20
21// Forward declaration
22extern void goQuitMainLoop();
23
24// Connect window close signal to quit main loop
25static void connect_destroy_quit(GtkWidget* w) {
26 if (!w) return;
27 g_signal_connect(G_OBJECT(w), "close-request", G_CALLBACK(on_close_request), NULL);
28}
29static WebKitWebView* as_webview(GtkWidget* w) { return WEBKIT_WEB_VIEW(w); }
30// WebsiteDataManager creation will be handled via GTK4/WebKit6 APIs in Go code.
31
32// Forward declare helpers used below
33static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy);
34
35// TLS error handling forward declarations
36extern gboolean goHandleTLSError(char* failing_uri, char* host, int error_flags, char* cert_info);
37static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
38 const char *failing_uri,
39 GTlsCertificate *certificate,
40 GTlsCertificateFlags errors,
41 gpointer user_data);
42
43// Handle create signal for _blank links - redirect to current window
44static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
45 WebKitNavigationAction* navigation_action,
46 gpointer user_data) {
47 (void)user_data;
48
49 // Get the request from navigation action
50 WebKitURIRequest* request = webkit_navigation_action_get_request(navigation_action);
51 if (request) {
52 const gchar* uri = webkit_uri_request_get_uri(request);
53 if (uri) {
54 // Load in current window instead of creating new one
55 webkit_web_view_load_uri(web_view, uri);
56 }
57 }
58
59 // Return NULL to prevent new window creation
60 return NULL;
61}
62static void connect_tls_error_handler(WebKitWebView* wv);
63
64// Dialog callback structure for modern AlertDialog async handling
65typedef struct {
66 int response;
67 gboolean completed;
68 GMainLoop *loop;
69} AlertDialogResponse;
70
71static void on_alert_dialog_choose_done(GObject *source, GAsyncResult *result, gpointer user_data);
72static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* hostname, const char* error_msg);
73static char* extract_certificate_info(GTlsCertificate* certificate);
74
75// Setup video acceleration environment variables
76static void setup_video_acceleration(const char* driver_name, int enable_all, int legacy) {
77 if (legacy) {
78 // Legacy mode: force old VA-API plugins, disable modern ones
79 setenv("WEBKIT_GST_ENABLE_LEGACY_VAAPI", "1", 1);
80 // Don't set other env vars in legacy mode to avoid conflicts
81 return;
82 }
83
84 // Modern mode (default): use improved VA-API handling
85 if (driver_name && strlen(driver_name) > 0) {
86 if (strcmp(driver_name, "nvidia") == 0) {
87 // NVIDIA uses different env vars for hardware video acceleration
88 setenv("VDPAU_DRIVER", "nvidia", 1);
89 setenv("LIBVA_DRIVER_NAME", "vdpau", 1);
90 } else if (strcmp(driver_name, "vdpau") == 0) {
91 // Explicit VDPAU backend (for NVIDIA via VA-API)
92 setenv("LIBVA_DRIVER_NAME", "vdpau", 1);
93 } else {
94 // AMD/Intel use VA-API directly
95 setenv("LIBVA_DRIVER_NAME", driver_name, 1);
96 }
97 }
98
99 if (enable_all) {
100 setenv("GST_VAAPI_ALL_DRIVERS", "1", 1);
101 }
102}
103
104// Construct a WebKitWebView via g_object_new with a fresh UserContentManager.
105static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const char* cache_dir, const char* cookie_path, WebKitUserContentManager** out_ucm, WebKitMemoryPressureSettings* pressure_settings) {
106 WebKitUserContentManager* u = webkit_user_content_manager_new();
107 if (!u) return NULL;
108 // WebKitGTK 6: create NetworkSession with data/cache directories
109 WebKitNetworkSession* sess = webkit_network_session_new(
110 data_dir ? data_dir : "",
111 cache_dir ? cache_dir : "");
112 if (!sess) { g_object_unref(u); return NULL; }
113 // Apply memory pressure settings to the session when provided
114 if (pressure_settings) {
115 // WebKitGTK 6.0 API: setter expects only the settings pointer
116 webkit_network_session_set_memory_pressure_settings(pressure_settings);
117 }
118 // Configure cookie persistence and policy (no third-party by default)
119 WebKitCookieManager* cm = webkit_network_session_get_cookie_manager(sess);
120 if (cm) {
121 if (cookie_path && cookie_path[0]) {
122 webkit_cookie_manager_set_persistent_storage(cm, cookie_path, WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
123 }
124 // 2 => no-third-party (default), 0 => always, 1 => never
125 maybe_set_cookie_policy(cm, 2);
126 }
127 // Persist credentials (HTTP auth, etc.)
128 webkit_network_session_set_persistent_credential_storage_enabled(sess, TRUE);
129 // Configure TLS errors to emit signals instead of failing silently
130 webkit_network_session_set_tls_errors_policy(sess, WEBKIT_TLS_ERRORS_POLICY_FAIL);
131 GtkWidget* w = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW,
132 "user-content-manager", u,
133 "network-session", sess,
134 NULL));
135 // WebView holds refs to provided objects; drop our temporary refs
136 g_object_unref(sess);
137 // Set WebView background to black (easier on eyes when pages are loading)
138 GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 };
139 webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color);
140
141 // Handle create signal to redirect _blank links to current window
142 g_signal_connect(G_OBJECT(w), "create", G_CALLBACK(on_create_new_window), NULL);
143
144 // Connect TLS error handler
145 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));
146 if (out_ucm) { *out_ucm = u; }
147 return w;
148}
149
150static gboolean gtk_prefers_dark() {
151 // Method 1: Check GNOME desktop interface color-scheme (primary method)
152 GSettings* desktop_settings = g_settings_new("org.gnome.desktop.interface");
153 if (desktop_settings) {
154 gchar* color_scheme = g_settings_get_string(desktop_settings, "color-scheme");
155 if (color_scheme) {
156 gboolean prefer_dark = (g_strcmp0(color_scheme, "prefer-dark") == 0);
157 g_free(color_scheme);
158 g_object_unref(desktop_settings);
159 if (prefer_dark) return TRUE;
160 }
161 g_object_unref(desktop_settings);
162 }
163
164 // Method 2: Check GTK theme name for dark variants
165 GtkSettings* settings = gtk_settings_get_default();
166 if (settings) {
167 gchar* theme_name = NULL;
168 g_object_get(settings, "gtk-theme-name", &theme_name, NULL);
169 if (theme_name) {
170 gboolean is_dark = (strstr(theme_name, "-dark") != NULL ||
171 strstr(theme_name, "-Dark") != NULL);
172 g_free(theme_name);
173 if (is_dark) return TRUE;
174 }
175
176 // Method 3: Check gtk-application-prefer-dark-theme (fallback)
177 gboolean prefer = FALSE;
178 g_object_get(settings, "gtk-application-prefer-dark-theme", &prefer, NULL);
179 if (prefer) return TRUE;
180 }
181
182 return FALSE;
183}
184
185// Note: preferred color scheme is handled via a user script injection to support
186// older WebKitGTK versions without the color-scheme API. See enableUserContentManager.
187
188extern void goOnUcmMessage(unsigned long id, char* json);
189
190// Helper functions to connect signals using static callbacks
191static void connect_title_notify(GtkWidget* widget, GtkWindow* window) {
192 if (!widget) return;
193 g_signal_connect_data(G_OBJECT(widget), "notify::title", G_CALLBACK(on_title_notify), G_OBJECT(window), NULL, 0);
194}
195
196static void connect_title_notify_with_id(GtkWidget* widget, unsigned long id) {
197 if (!widget) return;
198 g_signal_connect_data(G_OBJECT(widget), "notify::title", G_CALLBACK(on_title_notify_id), (gpointer)id, NULL, 0);
199}
200
201static void connect_uri_notify_with_id(GtkWidget* widget, unsigned long id) {
202 if (!widget) return;
203 g_signal_connect_data(G_OBJECT(widget), "notify::uri", G_CALLBACK(on_uri_notify), (gpointer)id, NULL, 0);
204}
205
206static void connect_theme_changed_with_id(GtkSettings* settings, unsigned long id) {
207 if (!settings) return;
208 g_signal_connect_data(G_OBJECT(settings), "notify::gtk-application-prefer-dark-theme", G_CALLBACK(on_theme_changed), (gpointer)id, NULL, 0);
209}
210
211static void register_uri_scheme_handler(WebKitWebContext* context, const char* scheme) {
212 if (!context || !scheme) return;
213 webkit_web_context_register_uri_scheme(context, scheme, (WebKitURISchemeRequestCallback)on_uri_scheme, NULL, NULL);
214}
215
216// Script message handler wiring for WebKitGTK 6
217// Forward declaration for callback used in g_signal_connect_data
218// WebKitGTK 6 delivers JSCValue* for script-message-received::NAME
219static void ucm_on_script_message_cb(WebKitUserContentManager* ucm, JSCValue* val, gpointer user_data);
220// Uses WebKitScriptMessage and script-message-received::NAME signal.
221static void ucm_on_script_message_cb(WebKitUserContentManager* ucm, JSCValue* val, gpointer user_data) {
222 (void)ucm;
223 if (!val) return;
224 gchar* s = jsc_value_to_string(val);
225 if (!s) return;
226 goOnUcmMessage((unsigned long)user_data, s);
227 g_free(s);
228}
229
230static gboolean register_ucm_handler(WebKitUserContentManager* ucm, const char* name, unsigned long id) {
231 if (!ucm || !name) return FALSE;
232 // WebKitGTK 6 signature: (ucm, name, world_name)
233 webkit_user_content_manager_register_script_message_handler(ucm, name, NULL);
234 gchar* signal = g_strdup_printf("script-message-received::%s", name);
235 if (!signal) return FALSE;
236 g_signal_connect_data(G_OBJECT(ucm), signal, G_CALLBACK(ucm_on_script_message_cb), (gpointer)id, NULL, 0);
237 g_free(signal);
238 return TRUE;
239}
240
241// ----- Conditional helpers for settings across WebKit versions -----
242#ifndef WEBKIT_CHECK_VERSION
243#define WEBKIT_CHECK_VERSION(major,minor,micro) (0)
244#endif
245
246static void maybe_set_hw_policy(WebKitSettings* settings, int mode) {
247#if WEBKIT_CHECK_VERSION(2,40,0)
248 // mode: 0=ON_DEMAND, 1=ALWAYS, 2=NEVER
249 if (!settings) return;
250 if (mode == 1) {
251 webkit_settings_set_hardware_acceleration_policy(settings, WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS);
252 } else if (mode == 2) {
253 webkit_settings_set_hardware_acceleration_policy(settings, WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER);
254 } else {
255 // Leave default policy (auto) without forcing a value to avoid referencing
256 // WEBKIT_HARDWARE_ACCELERATION_POLICY_ON_DEMAND on older headers.
257 }
258#else
259 (void)settings; (void)mode;
260#endif
261}
262
263static void maybe_set_draw_indicators(WebKitSettings* settings, int enable) {
264#if WEBKIT_CHECK_VERSION(2,36,0)
265 if (!settings) return;
266 webkit_settings_set_draw_compositing_indicators(settings, enable ? 1 : 0);
267#else
268 (void)settings; (void)enable;
269#endif
270}
271
272static void maybe_set_webgl(WebKitSettings* settings, int enable) {
273#if WEBKIT_CHECK_VERSION(2,20,0)
274 if (!settings) return;
275 webkit_settings_set_enable_webgl(settings, enable ? 1 : 0);
276#else
277 (void)settings; (void)enable;
278#endif
279}
280
281static void maybe_set_canvas_accel(WebKitSettings* settings, int enable) {
282#if WEBKIT_CHECK_VERSION(2,20,0)
283 if (!settings) return;
284 webkit_settings_set_enable_2d_canvas_acceleration(settings, enable ? 1 : 0);
285#else
286 (void)settings; (void)enable;
287#endif
288}
289
290static void maybe_set_media_user_gesture(WebKitSettings* settings, int require) {
291#if WEBKIT_CHECK_VERSION(2,20,0)
292 if (!settings) return;
293 webkit_settings_set_media_playback_requires_user_gesture(settings, require ? 1 : 0);
294#else
295 (void)settings; (void)require;
296#endif
297}
298
299// Enable two-finger swipe back/forward navigation gestures when supported
300static void maybe_set_back_forward_gestures(WebKitSettings* settings, int enable) {
301#if WEBKIT_CHECK_VERSION(2,38,0)
302 if (!settings) return;
303 webkit_settings_set_enable_back_forward_navigation_gestures(settings, enable ? 1 : 0);
304#else
305 (void)settings; (void)enable;
306#endif
307}
308
309// Set cookie accept policy with compile-time compatibility across WebKit versions
310static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy) {
311 if (!cm) return;
312#if defined(WEBKIT_COOKIE_ACCEPT_POLICY_ALWAYS)
313 WebKitCookieAcceptPolicy p = WEBKIT_COOKIE_ACCEPT_POLICY_ALWAYS;
314 if (policy == 1) p = WEBKIT_COOKIE_ACCEPT_POLICY_NEVER; // never
315 else if (policy == 2) p = WEBKIT_COOKIE_ACCEPT_POLICY_NO_THIRD_PARTY; // no third-party
316 webkit_cookie_manager_set_accept_policy(cm, p);
317#elif defined(WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS)
318 WebKitCookieAcceptPolicy p = WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS;
319 if (policy == 1) p = WEBKIT_COOKIE_POLICY_ACCEPT_NEVER;
320 else if (policy == 2) p = WEBKIT_COOKIE_POLICY_ACCEPT_NO_THIRD_PARTY;
321 webkit_cookie_manager_set_accept_policy(cm, p);
322#else
323 (void)policy; // Not supported in this header version
324#endif
325}
326
327// Memory pressure settings helpers (version-guarded)
328static WebKitMemoryPressureSettings* create_memory_pressure_settings(
329 unsigned int memory_limit_mb,
330 double conservative_threshold,
331 double strict_threshold,
332 double kill_threshold,
333 double poll_interval) {
334 // If headers don't expose memory pressure APIs, return NULL to signal unsupported
335#if WEBKIT_CHECK_VERSION(2,44,0)
336 WebKitMemoryPressureSettings* settings = webkit_memory_pressure_settings_new();
337 if (!settings) return NULL;
338
339 if (memory_limit_mb > 0) {
340 webkit_memory_pressure_settings_set_memory_limit(settings, (guint)(memory_limit_mb * 1024 * 1024));
341 }
342
343 webkit_memory_pressure_settings_set_conservative_threshold(settings, conservative_threshold);
344 webkit_memory_pressure_settings_set_strict_threshold(settings, strict_threshold);
345
346 if (kill_threshold > 0) {
347 webkit_memory_pressure_settings_set_kill_threshold(settings, kill_threshold);
348 }
349
350 webkit_memory_pressure_settings_set_poll_interval(settings, poll_interval);
351
352 return settings;
353#else
354 (void)memory_limit_mb; (void)conservative_threshold; (void)strict_threshold; (void)kill_threshold; (void)poll_interval;
355 return NULL;
356#endif
357}
358
359// Cache model configuration
360static void maybe_set_cache_model(WebKitWebContext* context, int model) {
361 if (!context) return;
362 WebKitCacheModel cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; // default
363
364 switch (model) {
365 case 0: cache_model = WEBKIT_CACHE_MODEL_DOCUMENT_VIEWER; break;
366 case 1: cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; break;
367 case 2:
368#ifdef WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER
369 cache_model = WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER;
370#else
371 cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER;
372#endif
373 break;
374 }
375
376 webkit_web_context_set_cache_model(context, cache_model);
377}
378
379// Additional settings for memory optimization
380static void maybe_set_page_cache(WebKitSettings* settings, int enable) {
381#if WEBKIT_CHECK_VERSION(2,6,0)
382 if (!settings) return;
383 webkit_settings_set_enable_page_cache(settings, enable ? TRUE : FALSE);
384#else
385 (void)settings; (void)enable;
386#endif
387}
388
389// Offline Web Application Cache is deprecated in modern WebKit; keep as no-op to avoid warnings.
390static void maybe_set_offline_app_cache(WebKitSettings* settings, int enable) {
391 (void)settings; (void)enable;
392}
393
394// Apply memory pressure settings to context if available
395static void maybe_set_memory_pressure_settings(WebKitWebContext* context, WebKitMemoryPressureSettings* settings) {
396 // No-op wrapper kept for compatibility; settings applied to NetworkSession instead.
397 (void)context; (void)settings;
398}
399
400// Trigger JavaScript GC if supported by headers
401static void maybe_collect_js(WebKitWebContext* context) {
402 // Disabled by default due to API availability variance across 6.0 builds.
403 (void)context;
404}
405
406// TLS error handling implementation
407static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
408 const char *failing_uri,
409 GTlsCertificate *certificate,
410 GTlsCertificateFlags errors,
411 gpointer user_data) {
412 (void)user_data;
413
414 // Extract host from URI for the Go callback
415 const char* host = failing_uri;
416 char* host_copy = NULL;
417
418 if (strstr(failing_uri, "://")) {
419 host = strstr(failing_uri, "://") + 3;
420 char* slash = strchr(host, '/');
421 if (slash) {
422 // Create a temporary null-terminated host string
423 size_t host_len = slash - host;
424 host_copy = malloc(host_len + 1);
425 if (host_copy) {
426 strncpy(host_copy, host, host_len);
427 host_copy[host_len] = '\0';
428 host = host_copy;
429 }
430 }
431 }
432
433 // Extract certificate information
434 char* cert_info = extract_certificate_info(certificate);
435
436 gboolean should_proceed = goHandleTLSError((char*)failing_uri, (char*)host, (int)errors, cert_info);
437
438 // If user accepted, allow the certificate for this host
439 if (should_proceed && certificate) {
440 printf("[dumber] User accepted certificate - adding exception and triggering new load\n");
441 fflush(stdout);
442 // Get the network session from the web view
443 WebKitNetworkSession* session = webkit_web_view_get_network_session(web_view);
444 if (session) {
445 webkit_network_session_allow_tls_certificate_for_host(session, certificate, host);
446 printf("[dumber] Certificate exception added for host: %s\n", host);
447 fflush(stdout);
448
449 // Try loading the URL again after adding the exception
450 webkit_web_view_load_uri(web_view, failing_uri);
451 printf("[dumber] Triggered new load of %s with certificate exception\n", failing_uri);
452 fflush(stdout);
453 }
454 }
455
456 // Free the certificate info string
457 if (cert_info) {
458 g_free(cert_info);
459 }
460
461 if (host_copy) {
462 free(host_copy);
463 }
464
465 return should_proceed;
466}
467
468// Extract certificate information for display
469static char* extract_certificate_info(GTlsCertificate* certificate) {
470 if (!certificate) {
471 return g_strdup("Certificate information not available");
472 }
473
474 printf("[dumber] Extracting certificate information...\n");
475 fflush(stdout);
476
477 GString* info = g_string_new("Certificate Information:\n");
478
479 // Try to get certificate properties - but these might not be available in all GTK versions
480 gchar* subject = NULL;
481 gchar* issuer = NULL;
482 GDateTime* not_valid_before = NULL;
483 GDateTime* not_valid_after = NULL;
484
485 // Try property access but handle failures gracefully
486 g_object_get(certificate,
487 "subject-name", &subject,
488 "issuer-name", &issuer,
489 "not-valid-before", ¬_valid_before,
490 "not-valid-after", ¬_valid_after,
491 NULL);
492
493 if (subject) {
494 g_string_append_printf(info, "Subject: %s\n", subject);
495 g_free(subject);
496 printf("[dumber] Found subject info\n");
497 } else {
498 g_string_append(info, "Subject: Information not available\n");
499 }
500
501 if (issuer) {
502 g_string_append_printf(info, "Issued by: %s\n", issuer);
503 g_free(issuer);
504 printf("[dumber] Found issuer info\n");
505 } else {
506 g_string_append(info, "Issued by: Information not available\n");
507 }
508
509 if (not_valid_before) {
510 gchar* date_str = g_date_time_format(not_valid_before, "%Y-%m-%d %H:%M:%S UTC");
511 g_string_append_printf(info, "Valid from: %s\n", date_str);
512 g_free(date_str);
513 g_date_time_unref(not_valid_before);
514 } else {
515 g_string_append(info, "Valid from: Information not available\n");
516 }
517
518 if (not_valid_after) {
519 gchar* date_str = g_date_time_format(not_valid_after, "%Y-%m-%d %H:%M:%S UTC");
520 g_string_append_printf(info, "Valid until: %s\n", date_str);
521 g_free(date_str);
522 g_date_time_unref(not_valid_after);
523 } else {
524 g_string_append(info, "Valid until: Information not available\n");
525 }
526
527 char* result = g_string_free(info, FALSE);
528 printf("[dumber] Certificate info: %s\n", result);
529 fflush(stdout);
530 return result;
531}
532
533static void connect_tls_error_handler(WebKitWebView* wv) {
534 if (!wv) return;
535 g_signal_connect(G_OBJECT(wv), "load-failed-with-tls-errors",
536 G_CALLBACK(on_load_failed_with_tls_errors), NULL);
537}
538
539// Modern AlertDialog async callback
540static void on_alert_dialog_choose_done(GObject *source, GAsyncResult *result, gpointer user_data) {
541 AlertDialogResponse *data = (AlertDialogResponse*)user_data;
542 GError *error = NULL;
543 data->response = gtk_alert_dialog_choose_finish(GTK_ALERT_DIALOG(source), result, &error);
544 if (error) {
545 printf("[dumber] AlertDialog error: %s\n", error->message);
546 g_error_free(error);
547 data->response = 0;
548 }
549 data->completed = TRUE;
550 const char *response_names[] = {"GO_BACK", "PROCEED_ONCE", "ALWAYS_ACCEPT"};
551 const char *response_name = (data->response >= 0 && data->response <= 2) ?
552 response_names[data->response] : "UNKNOWN";
553 printf("[dumber] TLS dialog response: %s (button_index=%d)\n", response_name, data->response);
554 fflush(stdout);
555 if (data->loop) {
556 g_main_loop_quit(data->loop);
557 }
558}
559
560// Show TLS warning dialog synchronously using modern GtkAlertDialog API
561static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* hostname, const char* error_msg) {
562 printf("[dumber] Creating TLS warning dialog for hostname: %s\n", hostname);
563 printf("[dumber] Error message: %s\n", error_msg);
564 fflush(stdout);
565
566 // Create the modern AlertDialog
567 GtkAlertDialog *dialog = gtk_alert_dialog_new("Certificate Error for %s", hostname);
568
569 // Set modal behavior and error details
570 gtk_alert_dialog_set_modal(dialog, TRUE);
571 gtk_alert_dialog_set_detail(dialog, error_msg);
572
573 // Configure buttons (Go Back = 0, Proceed Once = 1, Always Accept = 2)
574 const char *buttons[] = {"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site", NULL};
575 gtk_alert_dialog_set_buttons(dialog, buttons);
576 gtk_alert_dialog_set_default_button(dialog, 0);
577 gtk_alert_dialog_set_cancel_button(dialog, 0);
578
579 // Set up response data with main loop for sync behavior
580 AlertDialogResponse response_data = {0, FALSE, g_main_loop_new(NULL, FALSE)};
581
582 // Start async operation
583 gtk_alert_dialog_choose(dialog, parent, NULL,
584 on_alert_dialog_choose_done, &response_data);
585
586 // Run event loop until dialog completes
587 g_main_loop_run(response_data.loop);
588 g_main_loop_unref(response_data.loop);
589
590 // Clean up
591 g_object_unref(dialog);
592
593 // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept
594 return response_data.response;
595}
596*/
597import "C"
598
599import (
600 "context"
601 "crypto/sha256"
602 "database/sql"
603 "errors"
604 "fmt"
605 "log"
606 "os"
607 "path/filepath"
608 "runtime"
609 "strings"
610 "time"
611 "unsafe"
612
613 "github.com/bnema/dumber/internal/config"
614 "github.com/bnema/dumber/internal/db"
615 "github.com/bnema/dumber/internal/logging"
616 _ "github.com/ncruces/go-sqlite3"
617)
618
619// FilterManager interface for content blocking integration
620type FilterManager interface {
621 GetNetworkFilters() ([]byte, error)
622 GetCosmeticScript() string
623 GetCosmeticScriptForDomain(domain string) string
624}
625
626// Helper function to convert bool to int for C interop
627func boolToInt(b bool) int {
628 if b {
629 return 1
630 }
631 return 0
632}
633
634// getCertificateHash generates a SHA256 hash of the certificate info for storage
635func getCertificateHash(certificateInfo string) string {
636 hash := sha256.Sum256([]byte(certificateInfo))
637 return fmt.Sprintf("%x", hash)
638}
639
640// getDBConnection opens a database connection and returns queries
641func getDBConnection() (*db.Queries, *sql.DB, error) {
642 cfg := config.Get()
643 if cfg == nil {
644 return nil, nil, fmt.Errorf("config not available")
645 }
646
647 dbPath := cfg.Database.Path
648 if dbPath == "" {
649 var err error
650 dbPath, err = config.GetDatabaseFile()
651 if err != nil {
652 return nil, nil, fmt.Errorf("failed to get database path: %w", err)
653 }
654 }
655
656 database, err := sql.Open("sqlite3", dbPath)
657 if err != nil {
658 return nil, nil, fmt.Errorf("failed to open database: %w", err)
659 }
660
661 queries := db.New(database)
662 return queries, database, nil
663}
664
665// checkStoredCertificateDecision checks if user has previously decided on this certificate
666func checkStoredCertificateDecision(hostname, certificateInfo string) (string, bool) {
667 queries, database, err := getDBConnection()
668 if err != nil {
669 log.Printf("[tls] Failed to connect to database: %v", err)
670 return "", false
671 }
672 defer database.Close()
673
674 certHash := getCertificateHash(certificateInfo)
675 ctx := context.Background()
676
677 validation, err := queries.GetCertificateValidation(ctx, hostname, certHash)
678 if err != nil {
679 if err == sql.ErrNoRows {
680 return "", false // No previous decision
681 }
682 log.Printf("[tls] Failed to query certificate validation: %v", err)
683 return "", false
684 }
685
686 log.Printf("[tls] Found stored certificate decision for %s: %s", hostname, validation.UserDecision)
687 return validation.UserDecision, true
688}
689
690// storeCertificateDecision stores the user's decision about a certificate
691func storeCertificateDecision(hostname, certificateInfo, decision string, permanent bool) error {
692 queries, database, err := getDBConnection()
693 if err != nil {
694 return fmt.Errorf("failed to connect to database: %w", err)
695 }
696 defer database.Close()
697
698 certHash := getCertificateHash(certificateInfo)
699 ctx := context.Background()
700
701 var expiresAt sql.NullTime
702 if !permanent {
703 // For temporary decisions, expire after 24 hours
704 expiresAt = sql.NullTime{
705 Time: time.Now().Add(24 * time.Hour),
706 Valid: true,
707 }
708 }
709
710 err = queries.StoreCertificateValidation(ctx, hostname, certHash, decision, expiresAt)
711 if err != nil {
712 return fmt.Errorf("failed to store certificate validation: %w", err)
713 }
714
715 log.Printf("[tls] Stored certificate decision for %s: %s (permanent: %t)", hostname, decision, permanent)
716 return nil
717}
718
719type nativeView struct {
720 win *C.GtkWidget
721 view *C.GtkWidget
722 wv *C.WebKitWebView
723 ucm *C.WebKitUserContentManager
724}
725
726type memoryStats struct {
727 pageLoadCount int
728 lastGCTime time.Time
729 memoryPressureSettings *C.WebKitMemoryPressureSettings
730}
731
732// WebView represents a browser view powered by WebKit2GTK.
733type WebView struct {
734 config *Config
735 zoom float64
736 url string
737 destroyed bool
738 native *nativeView
739 window *Window
740 id uintptr
741 msgHandler func(payload string)
742 titleHandler func(title string)
743 uriHandler func(uri string)
744 zoomHandler func(level float64)
745 memStats *memoryStats
746 gcTicker *time.Ticker
747 gcDone chan struct{}
748 tlsExceptions map[string]bool // host -> whether user allowed certificate exception
749}
750
751// NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
752func NewWebView(cfg *Config) (*WebView, error) {
753 log.Printf("[webkit] Initializing GTK and creating WebView (CGO)")
754 if C.gtk_init_check() == 0 {
755 return nil, errors.New("failed to initialize GTK")
756 }
757
758 if cfg == nil {
759 cfg = &Config{}
760 }
761
762 // Setup video acceleration environment variables before creating WebView
763 if cfg.VideoAcceleration.EnableVAAPI {
764 driverName := C.CString(cfg.VideoAcceleration.VAAPIDriverName)
765 enableAll := 0
766 if cfg.VideoAcceleration.EnableAllDrivers {
767 enableAll = 1
768 }
769 legacy := 0
770 if cfg.VideoAcceleration.LegacyVAAPI {
771 legacy = 1
772 }
773 C.setup_video_acceleration(driverName, C.int(enableAll), C.int(legacy))
774 C.free(unsafe.Pointer(driverName))
775 }
776
777 // Prepare persistent website data/caches
778 dataDir := cfg.DataDir
779 cacheDir := cfg.CacheDir
780 if dataDir == "" {
781 dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data")
782 }
783 if cacheDir == "" {
784 cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache")
785 }
786 _ = os.MkdirAll(dataDir, 0o755)
787 _ = os.MkdirAll(cacheDir, 0o755)
788
789 cData := C.CString(dataDir)
790 cCache := C.CString(cacheDir)
791 cookieFile := filepath.Join(dataDir, "cookies.db")
792 cCookie := C.CString(cookieFile)
793 defer C.free(unsafe.Pointer(cData))
794 defer C.free(unsafe.Pointer(cCache))
795 defer C.free(unsafe.Pointer(cCookie))
796 // Use default WebContext to register the custom URI scheme handler; the
797 // WebView itself is created with a WebKitNetworkSession for persistence.
798 ctx := C.webkit_web_context_get_default()
799
800 // Apply cache model to WebContext
801 // Default to WEBKIT_CACHE_MODEL_WEB_BROWSER to keep caching enabled
802 cacheModel := 1
803 if cfg.Memory.CacheModel != "" {
804 switch cfg.Memory.CacheModel {
805 case CacheModelWebBrowser:
806 cacheModel = 1
807 case CacheModelPrimaryWebBrowser:
808 cacheModel = 2
809 default: // CacheModelDocumentViewer or unset
810 cacheModel = 0
811 }
812 }
813 C.maybe_set_cache_model(ctx, C.int(cacheModel))
814
815 // Configure memory pressure settings if specified
816 var pressureSettings *C.WebKitMemoryPressureSettings
817 // Only configure memory pressure if explicitly requested via config
818 if cfg.Memory.MemoryLimitMB > 0 || cfg.Memory.ConservativeThreshold > 0 || cfg.Memory.StrictThreshold > 0 || cfg.Memory.KillThreshold > 0 || cfg.Memory.PollIntervalSeconds > 0 {
819 memLimitMB := cfg.Memory.MemoryLimitMB
820 conservativeThreshold := cfg.Memory.ConservativeThreshold
821 strictThreshold := cfg.Memory.StrictThreshold
822 killThreshold := cfg.Memory.KillThreshold
823 pollInterval := cfg.Memory.PollIntervalSeconds
824
825 pressureSettings = C.create_memory_pressure_settings(
826 C.uint(memLimitMB),
827 C.double(conservativeThreshold),
828 C.double(strictThreshold),
829 C.double(killThreshold),
830 C.double(pollInterval))
831
832 if pressureSettings != nil {
833 C.maybe_set_memory_pressure_settings(ctx, pressureSettings)
834 if cfg.Memory.EnableMemoryMonitoring {
835 log.Printf("[webkit] Memory pressure settings applied: limit=%dMB, conservative=%.2f, strict=%.2f, poll=%.1fs",
836 memLimitMB, conservativeThreshold, strictThreshold, pollInterval)
837 }
838 }
839 }
840
841 // Register custom URI scheme handler for "dumb://"
842 {
843 sch := C.CString("dumb")
844 C.register_uri_scheme_handler(ctx, sch)
845 C.free(unsafe.Pointer(sch))
846 }
847 // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session
848
849 // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style)
850 var createdUcm *C.WebKitUserContentManager
851 viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm, pressureSettings)
852 if viewWidget == nil {
853 return nil, errors.New("failed to create WebKitWebView")
854 }
855
856 // Create a top-level window to host the view
857 win := C.new_window()
858 if win == nil {
859 return nil, errors.New("failed to create GtkWindow")
860 }
861
862 // Pack view widget into the window
863 // GTK4: containers removed; use gtk_window_set_child
864 C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget)
865 C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
866 C.connect_destroy_quit(win)
867
868 v := &WebView{
869 config: cfg,
870 zoom: 1.0,
871 native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
872 window: &Window{win: win},
873 memStats: &memoryStats{
874 pageLoadCount: 0,
875 lastGCTime: time.Now(),
876 memoryPressureSettings: pressureSettings,
877 },
878 gcDone: make(chan struct{}),
879 tlsExceptions: make(map[string]bool),
880 }
881 // Assign an ID for accelerator dispatch
882 v.id = nextViewID()
883 registerView(v.id, v)
884
885 // Register with global memory manager if monitoring is enabled
886 if cfg.Memory.EnableMemoryMonitoring {
887 if globalMemoryManager == nil {
888 InitializeGlobalMemoryManager(
889 cfg.Memory.EnableMemoryMonitoring,
890 cfg.Memory.ProcessRecycleThreshold,
891 30*time.Second, // Default monitoring interval
892 )
893 }
894 if globalMemoryManager != nil {
895 globalMemoryManager.RegisterWebView(v)
896 }
897 }
898 // Attach GTK4 input controllers (keyboard, mouse)
899 AttachKeyboardControllers(v)
900
901 // Watch for GTK theme changes and propagate to page at runtime
902 if settings := C.gtk_settings_get_default(); settings != nil {
903 C.connect_theme_changed_with_id(settings, C.ulong(v.id))
904 }
905
906 // Native zoom shortcuts (independent of app services)
907 // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset
908 _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() {
909 nz := v.zoom
910 if nz <= 0 {
911 nz = 1.0
912 }
913 nz *= 1.1
914 if nz < 0.25 {
915 nz = 0.25
916 }
917 if nz > 5.0 {
918 nz = 5.0
919 }
920 _ = v.SetZoom(nz)
921 })
922 _ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() {
923 nz := v.zoom
924 if nz <= 0 {
925 nz = 1.0
926 }
927 nz *= 1.1
928 if nz < 0.25 {
929 nz = 0.25
930 }
931 if nz > 5.0 {
932 nz = 5.0
933 }
934 _ = v.SetZoom(nz)
935 })
936 _ = v.RegisterKeyboardShortcut("cmdorctrl-", func() {
937 nz := v.zoom
938 if nz <= 0 {
939 nz = 1.0
940 }
941 nz /= 1.1
942 if nz < 0.25 {
943 nz = 0.25
944 }
945 if nz > 5.0 {
946 nz = 5.0
947 }
948 _ = v.SetZoom(nz)
949 })
950 _ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) })
951 // Find in page (Ctrl/Cmd+F) reuses omnibox component in find mode
952 _ = v.RegisterKeyboardShortcut("cmdorctrl+f", func() { _ = v.OpenFind("") })
953 // Navigation with Alt+Arrow keys
954 _ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() { _ = v.GoBack() })
955 _ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() { _ = v.GoForward() })
956 // Update window title when page title changes
957 C.connect_title_notify(viewWidget, (*C.GtkWindow)(unsafe.Pointer(win)))
958 // Also dispatch title change to Go with view id
959 C.connect_title_notify_with_id(viewWidget, C.ulong(v.id))
960 // Notify URI changes to Go to keep current URL in sync
961 C.connect_uri_notify_with_id(viewWidget, C.ulong(v.id))
962 // Apply hardware acceleration and related settings based on cfg.Rendering
963 if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
964 // Hardware acceleration policy (guarded by version)
965 switch cfg.Rendering.Mode {
966 case "gpu":
967 C.maybe_set_hw_policy(settings, 1)
968 C.maybe_set_webgl(settings, 1)
969 C.maybe_set_canvas_accel(settings, 1)
970 case "cpu":
971 C.maybe_set_hw_policy(settings, 2)
972 C.maybe_set_webgl(settings, 0)
973 C.maybe_set_canvas_accel(settings, 0)
974 default: // auto
975 C.maybe_set_hw_policy(settings, 0)
976 C.maybe_set_webgl(settings, 1)
977 C.maybe_set_canvas_accel(settings, 1)
978 }
979 // Optional compositing indicators for debugging (guarded by version)
980 if cfg.Rendering.DebugGPU {
981 C.maybe_set_draw_indicators(settings, 1)
982 }
983 // Reduce media pipeline churn by requiring a user gesture for playback
984 C.maybe_set_media_user_gesture(settings, 1)
985 // Enable trackpad back/forward gestures when available
986 C.maybe_set_back_forward_gestures(settings, 1)
987
988 // Keep page cache enabled by default for performance
989 enablePageCache := true
990 if cfg.Memory.EnablePageCache {
991 enablePageCache = true
992 }
993 C.maybe_set_page_cache(settings, C.int(boolToInt(enablePageCache)))
994
995 // Offline app cache is deprecated; keep as no-op
996 C.maybe_set_offline_app_cache(settings, C.int(0))
997
998 if cfg.Memory.EnableMemoryMonitoring {
999 log.Printf("[webkit] Memory settings applied: page_cache=%v", enablePageCache)
1000 }
1001 }
1002
1003 if cfg.ZoomDefault > 0 {
1004 v.zoom = cfg.ZoomDefault
1005 C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom))
1006 }
1007 if cfg.EnableDeveloperExtras {
1008 settings := C.webkit_web_view_get_settings(v.native.wv)
1009 if settings != nil {
1010 C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))
1011 }
1012 }
1013
1014 // Apply custom User-Agent for codec negotiation
1015 if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1016 if cfg.CodecPreferences.CustomUserAgent != "" {
1017 cUserAgent := C.CString(cfg.CodecPreferences.CustomUserAgent)
1018 C.webkit_settings_set_user_agent(settings, (*C.gchar)(cUserAgent))
1019 C.free(unsafe.Pointer(cUserAgent))
1020 log.Printf("[webkit] Set custom User-Agent: %s", cfg.CodecPreferences.CustomUserAgent)
1021 } else if cfg.CodecPreferences.ForceAV1 {
1022 // Use modern Chrome UA that signals AV1 support
1023 av1UA := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
1024 cAV1UA := C.CString(av1UA)
1025 C.webkit_settings_set_user_agent(settings, (*C.gchar)(cAV1UA))
1026 C.free(unsafe.Pointer(cAV1UA))
1027 log.Printf("[webkit] Set AV1-optimized User-Agent")
1028 }
1029 }
1030
1031 // Apply default fonts if provided in cfg
1032 if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1033 if cfg.DefaultSansFont != "" {
1034 csans := C.CString(cfg.DefaultSansFont)
1035 C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans))
1036 C.free(unsafe.Pointer(csans))
1037 // Also set default font family to sans
1038 csans2 := C.CString(cfg.DefaultSansFont)
1039 C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2))
1040 C.free(unsafe.Pointer(csans2))
1041 }
1042 if cfg.DefaultSerifFont != "" {
1043 cserif := C.CString(cfg.DefaultSerifFont)
1044 C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif))
1045 C.free(unsafe.Pointer(cserif))
1046 }
1047 if cfg.DefaultMonospaceFont != "" {
1048 cmono := C.CString(cfg.DefaultMonospaceFont)
1049 C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono))
1050 C.free(unsafe.Pointer(cmono))
1051 }
1052 if cfg.DefaultFontSize > 0 {
1053 C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize))
1054 }
1055 }
1056 if cfg.InitialURL != "" {
1057 _ = v.LoadURL(cfg.InitialURL)
1058 }
1059 // Ensure native resources are released if user forgets to call Destroy
1060 runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() })
1061 log.Printf("[webkit] WebView created (CGO)")
1062
1063 // Initialize UCM scripts and handlers
1064 if v.native != nil && v.native.ucm != nil {
1065 v.enableUserContentManager(cfg)
1066 }
1067
1068 // Start periodic JavaScript garbage collection if configured
1069 if cfg.Memory.EnableGCInterval > 0 {
1070 v.startPeriodicGC(time.Duration(cfg.Memory.EnableGCInterval) * time.Second)
1071 if cfg.Memory.EnableMemoryMonitoring {
1072 log.Printf("[webkit] Periodic GC enabled: interval=%ds", cfg.Memory.EnableGCInterval)
1073 }
1074 }
1075
1076 return v, nil
1077}
1078
1079func (w *WebView) LoadURL(rawURL string) error {
1080 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1081 return ErrNotImplemented
1082 }
1083
1084 // Track page loads for process recycling
1085 if w.memStats != nil {
1086 w.memStats.pageLoadCount++
1087
1088 // Check if we need to recycle this WebView
1089 if w.config.Memory.ProcessRecycleThreshold > 0 &&
1090 w.memStats.pageLoadCount >= w.config.Memory.ProcessRecycleThreshold {
1091 if w.config.Memory.EnableMemoryMonitoring {
1092 log.Printf("[webkit] Process recycle threshold reached (%d pages), consider creating new WebView",
1093 w.memStats.pageLoadCount)
1094 }
1095 }
1096 }
1097
1098 w.url = rawURL
1099 curl := C.CString(rawURL)
1100 defer C.free(unsafe.Pointer(curl))
1101 C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl))
1102 log.Printf("[webkit] LoadURL: %s", rawURL)
1103 return nil
1104}
1105
1106func (w *WebView) Show() error {
1107 if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
1108 return ErrNotImplemented
1109 }
1110 C.gtk_widget_set_visible(w.native.view, C.gboolean(1))
1111 C.gtk_widget_set_visible(w.native.win, C.gboolean(1))
1112 C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1113 log.Printf("[webkit] Show window")
1114 return nil
1115}
1116
1117func (w *WebView) Hide() error {
1118 if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
1119 return ErrNotImplemented
1120 }
1121 C.gtk_widget_set_visible(w.native.view, C.gboolean(0))
1122 C.gtk_widget_set_visible(w.native.win, C.gboolean(0))
1123 return nil
1124}
1125
1126func (w *WebView) Destroy() error {
1127 if w == nil || w.native == nil || w.native.win == nil {
1128 return ErrNotImplemented
1129 }
1130
1131 // Stop periodic GC if running
1132 w.stopPeriodicGC()
1133
1134 // Clean up memory pressure settings
1135 if w.memStats != nil && w.memStats.memoryPressureSettings != nil {
1136 // WebKit will clean up the memory pressure settings when context is destroyed
1137 w.memStats.memoryPressureSettings = nil
1138 }
1139
1140 // Unregister from memory manager
1141 if globalMemoryManager != nil {
1142 globalMemoryManager.UnregisterWebView(w.id)
1143 }
1144
1145 // GTK4: destroy windows via gtk_window_destroy
1146 C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1147 w.destroyed = true
1148 unregisterView(w.id)
1149 log.Printf("[webkit] Destroy window")
1150 return nil
1151}
1152
1153// Window returns the associated native window.
1154func (w *WebView) Window() *Window { return w.window }
1155
1156// GetCurrentURL returns the current URI from WebKit.
1157func (w *WebView) GetCurrentURL() string {
1158 if w == nil || w.native == nil || w.native.wv == nil {
1159 return ""
1160 }
1161 uri := C.webkit_web_view_get_uri(w.native.wv)
1162 if uri == nil {
1163 return ""
1164 }
1165 return C.GoString((*C.char)(unsafe.Pointer(uri)))
1166}
1167
1168func (w *WebView) GoBack() error {
1169 if w == nil || w.native == nil || w.native.wv == nil {
1170 return ErrNotImplemented
1171 }
1172 C.webkit_web_view_go_back(w.native.wv)
1173 return nil
1174}
1175
1176func (w *WebView) GoForward() error {
1177 if w == nil || w.native == nil || w.native.wv == nil {
1178 return ErrNotImplemented
1179 }
1180 C.webkit_web_view_go_forward(w.native.wv)
1181 return nil
1182}
1183
1184func (w *WebView) Reload() error {
1185 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1186 return ErrNotImplemented
1187 }
1188 C.webkit_web_view_reload(w.native.wv)
1189 log.Printf("[webkit] Reload page")
1190 return nil
1191}
1192
1193func (w *WebView) ReloadBypassCache() error {
1194 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1195 return ErrNotImplemented
1196 }
1197 C.webkit_web_view_reload_bypass_cache(w.native.wv)
1198 log.Printf("[webkit] Hard reload page (bypass cache)")
1199 return nil
1200}
1201
1202// RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
1203func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
1204
1205func (w *WebView) dispatchScriptMessage(payload string) {
1206 if w != nil && w.msgHandler != nil {
1207 w.msgHandler(payload)
1208 }
1209}
1210
1211// RegisterTitleChangedHandler registers a callback invoked when the page title changes.
1212func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
1213
1214func (w *WebView) dispatchTitleChanged(title string) {
1215 if w != nil && w.titleHandler != nil {
1216 w.titleHandler(title)
1217 }
1218}
1219
1220// RegisterURIChangedHandler registers a callback invoked when the current page URI changes.
1221func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }
1222
1223func (w *WebView) dispatchURIChanged(uri string) {
1224 if w != nil && w.uriHandler != nil {
1225 w.uriHandler(uri)
1226 }
1227}
1228
1229// RegisterZoomChangedHandler registers a callback invoked when zoom level changes.
1230func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb }
1231
1232func (w *WebView) dispatchZoomChanged(level float64) {
1233 if w != nil && w.zoomHandler != nil {
1234 w.zoomHandler(level)
1235 }
1236}
1237
1238// enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
1239func (w *WebView) enableUserContentManager(cfg *Config) {
1240 if w == nil || w.native == nil || w.native.ucm == nil {
1241 return
1242 }
1243 // Register handler "dumber"
1244 cname := C.CString("dumber")
1245 ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id))
1246 if ok == 0 {
1247 log.Printf("[webkit] Failed to register UCM handler; fallback bridge will handle messages")
1248 }
1249 C.free(unsafe.Pointer(cname))
1250
1251 // Inject color-scheme preference script at document-start to inform sites of system theme
1252 preferDark := C.gtk_prefers_dark() != 0
1253 if preferDark {
1254 log.Printf("[theme] GTK prefers: dark")
1255 } else {
1256 log.Printf("[theme] GTK prefers: light")
1257 }
1258 var schemeJS string
1259 if preferDark {
1260 schemeJS = "(() => { try { const d=true; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='dark light'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:dark;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
1261 } else {
1262 schemeJS = "(() => { try { const d=false; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='light dark'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:light;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
1263 }
1264 cScheme := C.CString(schemeJS)
1265 defer C.free(unsafe.Pointer(cScheme))
1266 schemeScript := C.webkit_user_script_new((*C.gchar)(cScheme), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
1267 if schemeScript != nil {
1268 C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript)
1269 C.webkit_user_script_unref(schemeScript)
1270 }
1271
1272 // Add user script at document-start (omnibox/find reusable component)
1273 src := C.CString(getOmniboxScript())
1274 defer C.free(unsafe.Pointer(src))
1275 script := C.webkit_user_script_new((*C.gchar)(src), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
1276 if script != nil {
1277 C.webkit_user_content_manager_add_script(w.native.ucm, script)
1278 C.webkit_user_script_unref(script)
1279 log.Printf("[webkit] UCM omnibox script injected at document-start")
1280 }
1281
1282 // Add user script at document-start (toast notification system)
1283 toastSrc := C.CString(getToastScript())
1284 defer C.free(unsafe.Pointer(toastSrc))
1285 toastScript := C.webkit_user_script_new((*C.gchar)(toastSrc), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
1286 if toastScript != nil {
1287 C.webkit_user_content_manager_add_script(w.native.ucm, toastScript)
1288 C.webkit_user_script_unref(toastScript)
1289 log.Printf("[webkit] UCM toast script injected at document-start")
1290 }
1291
1292 // Inject Wails runtime fetch interceptor for homepage bridging
1293 wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();`
1294 cWails := C.CString(wailsBridge)
1295 defer C.free(unsafe.Pointer(cWails))
1296 wailsScript := C.webkit_user_script_new((*C.gchar)(cWails), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
1297 if wailsScript != nil {
1298 C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript)
1299 C.webkit_user_script_unref(wailsScript)
1300 }
1301
1302 // Inject codec control script if codec preferences are configured
1303 if cfg != nil && (cfg.CodecPreferences.ForceAV1 || len(cfg.CodecPreferences.BlockedCodecs) > 0 || len(cfg.CodecPreferences.PreferredCodecs) > 0) {
1304 codecScript := GenerateCodecControlScript(cfg.CodecPreferences)
1305 cCodec := C.CString(codecScript)
1306 defer C.free(unsafe.Pointer(cCodec))
1307 codecUserScript := C.webkit_user_script_new(
1308 (*C.gchar)(cCodec),
1309 C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
1310 C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
1311 nil, nil)
1312 if codecUserScript != nil {
1313 C.webkit_user_content_manager_add_script(w.native.ucm, codecUserScript)
1314 C.webkit_user_script_unref(codecUserScript)
1315 log.Printf("[webkit] Codec control script injected")
1316 }
1317 }
1318
1319 // No JS fallback bridge — native UCM is active
1320}
1321
1322// startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically
1323func (w *WebView) startPeriodicGC(interval time.Duration) {
1324 if w == nil || w.gcTicker != nil {
1325 return // Already running or invalid
1326 }
1327
1328 w.gcTicker = time.NewTicker(interval)
1329
1330 go func() {
1331 defer func() {
1332 if w.gcTicker != nil {
1333 w.gcTicker.Stop()
1334 w.gcTicker = nil
1335 }
1336 }()
1337
1338 for {
1339 select {
1340 case <-w.gcTicker.C:
1341 w.triggerGC()
1342 case <-w.gcDone:
1343 return
1344 }
1345 }
1346 }()
1347}
1348
1349// stopPeriodicGC stops the periodic garbage collection
1350func (w *WebView) stopPeriodicGC() {
1351 if w == nil {
1352 return
1353 }
1354
1355 if w.gcDone != nil {
1356 close(w.gcDone)
1357 w.gcDone = nil
1358 }
1359
1360 if w.gcTicker != nil {
1361 w.gcTicker.Stop()
1362 w.gcTicker = nil
1363 }
1364}
1365
1366// triggerGC manually triggers JavaScript garbage collection
1367func (w *WebView) triggerGC() {
1368 if w == nil || w.memStats == nil {
1369 return
1370 }
1371
1372 // Get default WebContext and trigger GC
1373 ctx := C.webkit_web_context_get_default()
1374 if ctx != nil {
1375 C.maybe_collect_js(ctx)
1376 w.memStats.lastGCTime = time.Now()
1377
1378 if w.config.Memory.EnableMemoryMonitoring {
1379 log.Printf("[webkit] JavaScript GC triggered (page loads: %d)", w.memStats.pageLoadCount)
1380 }
1381 }
1382}
1383
1384// GetMemoryStats returns current memory statistics for this WebView
1385func (w *WebView) GetMemoryStats() map[string]interface{} {
1386 if w == nil || w.memStats == nil {
1387 return nil
1388 }
1389
1390 return map[string]interface{}{
1391 "page_load_count": w.memStats.pageLoadCount,
1392 "last_gc_time": w.memStats.lastGCTime,
1393 "has_memory_pressure_settings": w.memStats.memoryPressureSettings != nil,
1394 }
1395}
1396
1397// TriggerMemoryCleanup manually triggers garbage collection and memory cleanup
1398func (w *WebView) TriggerMemoryCleanup() {
1399 if w == nil {
1400 return
1401 }
1402
1403 w.triggerGC()
1404
1405 if w.config.Memory.EnableMemoryMonitoring {
1406 log.Printf("[webkit] Manual memory cleanup triggered")
1407 }
1408}
1409
1410//export goHandleTLSError
1411func goHandleTLSError(failingURI *C.char, host *C.char, errorFlags C.int, certInfo *C.char) C.gboolean {
1412 uri := C.GoString(failingURI)
1413 hostname := C.GoString(host)
1414 certificateInfo := C.GoString(certInfo)
1415
1416 log.Printf("[tls] Certificate error for %s (URI: %s, flags: %d)", hostname, uri, int(errorFlags))
1417
1418 // Find the WebView that triggered this error
1419 // For now, we'll get the most recently created view
1420 // In a more sophisticated implementation, we'd pass the view ID through user_data
1421 var webView *WebView
1422 regMu.RLock()
1423 for _, v := range viewByID {
1424 if v != nil {
1425 webView = v
1426 break
1427 }
1428 }
1429 regMu.RUnlock()
1430
1431 if webView == nil {
1432 log.Printf("[tls] No WebView found to handle TLS error")
1433 return C.FALSE // Don't proceed
1434 }
1435
1436 // Check if we've already allowed this host
1437 if allowed, exists := webView.tlsExceptions[hostname]; exists && allowed {
1438 log.Printf("[tls] Certificate exception already granted for %s", hostname)
1439 return C.TRUE // Proceed with the load
1440 }
1441
1442 // Show warning dialog and get user decision
1443 if webView.showTLSWarningDialog(hostname, uri, int(errorFlags), certificateInfo) {
1444 webView.tlsExceptions[hostname] = true
1445 log.Printf("[tls] User accepted certificate exception for %s", hostname)
1446 return C.TRUE // Proceed with the load
1447 }
1448
1449 log.Printf("[tls] User rejected certificate for %s", hostname)
1450 return C.FALSE // Don't proceed
1451}
1452
1453// showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability
1454func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {
1455 if w == nil || w.native == nil || w.native.win == nil {
1456 log.Printf("[tls] Cannot show dialog: WebView or window is nil")
1457 return false
1458 }
1459
1460 // Check if we have a stored decision for this certificate
1461 if storedDecision, hasStored := checkStoredCertificateDecision(hostname, certificateInfo); hasStored {
1462 switch storedDecision {
1463 case "accepted":
1464 log.Printf("[tls] Using stored ACCEPT decision for %s", hostname)
1465 return true
1466 case "rejected":
1467 log.Printf("[tls] Using stored REJECT decision for %s", hostname)
1468 return false
1469 default:
1470 log.Printf("[tls] Unknown stored decision '%s' for %s, showing dialog", storedDecision, hostname)
1471 }
1472 }
1473
1474 log.Printf("[tls] Showing certificate error dialog for %s", hostname)
1475
1476 // Format the error message with certificate information
1477 errorMsg := formatTLSErrorMessage(hostname, uri, errorFlags, certificateInfo)
1478
1479 // Convert strings to C strings
1480 cHostname := C.CString(hostname)
1481 defer C.free(unsafe.Pointer(cHostname))
1482
1483 cErrorMsg := C.CString(errorMsg)
1484 defer C.free(unsafe.Pointer(cErrorMsg))
1485
1486 // Show the dialog and get user response (0=Go Back, 1=Proceed Once, 2=Always Accept)
1487 result := C.show_tls_warning_dialog_sync(
1488 (*C.GtkWindow)(unsafe.Pointer(w.native.win)),
1489 cHostname,
1490 cErrorMsg,
1491 )
1492
1493 responseCode := int(result)
1494
1495 switch responseCode {
1496 case 0: // Go Back
1497 log.Printf("[tls] User chose to GO BACK for %s", hostname)
1498 return false
1499
1500 case 1: // Proceed Once
1501 log.Printf("[tls] User chose to PROCEED ONCE for %s", hostname)
1502 // Store temporary decision (expires in 24 hours)
1503 if err := storeCertificateDecision(hostname, certificateInfo, "accepted", false); err != nil {
1504 log.Printf("[tls] Warning: Failed to store temporary certificate decision: %v", err)
1505 }
1506 return true
1507
1508 case 2: // Always Accept
1509 log.Printf("[tls] User chose to ALWAYS ACCEPT for %s", hostname)
1510 // Store permanent decision
1511 if err := storeCertificateDecision(hostname, certificateInfo, "accepted", true); err != nil {
1512 log.Printf("[tls] Warning: Failed to store permanent certificate decision: %v", err)
1513 }
1514 return true
1515
1516 default:
1517 log.Printf("[tls] Unknown response code %d for %s, defaulting to reject", responseCode, hostname)
1518 return false
1519 }
1520}
1521
1522// formatTLSErrorMessage creates a detailed error message based on the error flags
1523func formatTLSErrorMessage(hostname, uri string, errorFlags int, certificateInfo string) string {
1524 msg := "Website: " + hostname + "\n\n"
1525
1526 // Add certificate information
1527 if certificateInfo != "" {
1528 msg += "Certificate Details:\n" + certificateInfo + "\n"
1529 }
1530
1531 msg += "Security Issues:\n"
1532 // Decode GTlsCertificateFlags (common values)
1533 if errorFlags&1 != 0 { // G_TLS_CERTIFICATE_UNKNOWN_CA
1534 msg += "• Certificate authority is not trusted\n"
1535 }
1536 if errorFlags&2 != 0 { // G_TLS_CERTIFICATE_BAD_IDENTITY
1537 msg += "• Certificate does not match the website identity\n"
1538 }
1539 if errorFlags&4 != 0 { // G_TLS_CERTIFICATE_NOT_ACTIVATED
1540 msg += "• Certificate is not yet valid\n"
1541 }
1542 if errorFlags&8 != 0 { // G_TLS_CERTIFICATE_EXPIRED
1543 msg += "• Certificate has expired\n"
1544 }
1545 if errorFlags&16 != 0 { // G_TLS_CERTIFICATE_REVOKED
1546 msg += "• Certificate has been revoked\n"
1547 }
1548 if errorFlags&32 != 0 { // G_TLS_CERTIFICATE_INSECURE
1549 msg += "• Certificate uses weak cryptography\n"
1550 }
1551
1552 msg += "\nProceeding may expose your data to attackers.\nOnly continue if you trust this website."
1553
1554 return msg
1555}
1556
1557// InitializeContentBlocking initializes WebKit content blocking with filter manager
1558func (w *WebView) InitializeContentBlocking(filterManager FilterManager) error {
1559 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1560 return ErrWebViewNotInitialized
1561 }
1562
1563 // Load filters asynchronously with proper error recovery
1564 go func() {
1565 defer func() {
1566 if r := recover(); r != nil {
1567 logging.Error(fmt.Sprintf("[webkit] Content blocking initialization panic: %v", r))
1568 }
1569 }()
1570
1571 // Wait longer for WebView to be ready before applying filters on first load
1572 // This prevents interference with WebKit's preconnect operations
1573 time.Sleep(1500 * time.Millisecond)
1574
1575 // Retry mechanism with exponential backoff
1576 maxRetries := 5
1577 baseDelay := 200 * time.Millisecond
1578
1579 // Apply network filters with error recovery
1580 for i := 0; i < maxRetries; i++ {
1581 if w.destroyed {
1582 logging.Debug("[webkit] WebView destroyed, aborting filter loading")
1583 return
1584 }
1585
1586 if filters, err := filterManager.GetNetworkFilters(); err == nil {
1587 if len(filters) > 10 && string(filters) != "null" {
1588 logging.Info("[webkit] Loading network filters from filter manager (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
1589 if err := w.ApplyContentFilters(filters, "network-filters"); err != nil {
1590 logging.Error("Failed to apply network filters (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error())
1591 if i == maxRetries-1 {
1592 logging.Warn("[webkit] Network filtering disabled due to errors")
1593 break
1594 }
1595 time.Sleep(baseDelay * time.Duration(1<<uint(i))) // Exponential backoff
1596 continue
1597 }
1598 break
1599 }
1600 } else if i == maxRetries-1 {
1601 logging.Error("Failed to get network filters after retries: " + err.Error())
1602 }
1603 }
1604
1605 // Apply cosmetic filtering with error recovery
1606 time.Sleep(200 * time.Millisecond) // Additional delay to ensure network setup is complete
1607
1608 for i := 0; i < maxRetries; i++ {
1609 if w.destroyed {
1610 logging.Debug("[webkit] WebView destroyed, aborting cosmetic filter loading")
1611 return
1612 }
1613
1614 script := filterManager.GetCosmeticScript()
1615 if len(script) > 0 {
1616 logging.Info("[webkit] Loading cosmetic script from filter manager (" + fmt.Sprintf("%d", len(script)) + " chars)")
1617 if err := w.InjectCosmeticFilter(script); err != nil {
1618 logging.Error("Failed to inject cosmetic filter (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error())
1619 if i == maxRetries-1 {
1620 logging.Warn("[webkit] Cosmetic filtering disabled due to errors")
1621 } else {
1622 time.Sleep(baseDelay * time.Duration(1<<uint(i)))
1623 continue
1624 }
1625 }
1626 break
1627 }
1628 }
1629
1630 logging.Info("Content blocking initialization completed")
1631 }()
1632
1633 return nil
1634}
1635
1636// OnNavigate sets up domain-specific cosmetic filtering on navigation
1637func (w *WebView) OnNavigate(url string, filterManager FilterManager) {
1638 if w == nil || w.destroyed {
1639 return
1640 }
1641
1642 domain := extractDomain(url)
1643 if domain == "" {
1644 return
1645 }
1646
1647 // Get domain-specific cosmetic rules
1648 script := filterManager.GetCosmeticScriptForDomain(domain)
1649 if script != "" {
1650 // Inject domain-specific cosmetic rules
1651 logging.Info("[webkit] Applying domain-specific cosmetic rules for: " + domain + " (" + fmt.Sprintf("%d", len(script)) + " chars)")
1652 w.InjectScript(fmt.Sprintf(
1653 "if (typeof window.__dumber_cosmetic_init === 'function') { %s }",
1654 script,
1655 ))
1656 } else {
1657 logging.Debug("[webkit] No domain-specific cosmetic rules found for: " + domain)
1658 }
1659}
1660
1661// UpdateContentFilters updates the content filters dynamically
1662func (w *WebView) UpdateContentFilters(filterManager FilterManager) error {
1663 if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1664 return ErrWebViewNotInitialized
1665 }
1666
1667 // Clear existing filters first
1668 if err := w.ClearAllFilters(); err != nil {
1669 return fmt.Errorf("failed to clear existing filters: %w", err)
1670 }
1671
1672 // Apply new filters
1673 if filters, err := filterManager.GetNetworkFilters(); err == nil {
1674 logging.Info("[webkit] Updating network filters (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
1675 if err := w.ApplyContentFilters(filters, "network-filters-updated"); err != nil {
1676 return fmt.Errorf("failed to apply updated network filters: %w", err)
1677 }
1678 }
1679
1680 // Update cosmetic filtering
1681 script := filterManager.GetCosmeticScript()
1682 logging.Info("[webkit] Updating cosmetic script (" + fmt.Sprintf("%d", len(script)) + " chars)")
1683 if err := w.InjectCosmeticFilter(script); err != nil {
1684 return fmt.Errorf("failed to inject updated cosmetic filter: %w", err)
1685 }
1686
1687 logging.Info("Content filters updated successfully")
1688 return nil
1689}
1690
1691// extractDomain extracts the domain from a URL
1692func extractDomain(url string) string {
1693 if url == "" {
1694 return ""
1695 }
1696
1697 // Simple domain extraction - remove protocol and path
1698 start := strings.Index(url, "://")
1699 if start != -1 {
1700 url = url[start+3:]
1701 }
1702
1703 end := strings.Index(url, "/")
1704 if end != -1 {
1705 url = url[:end]
1706 }
1707
1708 // Remove port if present
1709 if portIdx := strings.Index(url, ":"); portIdx != -1 {
1710 url = url[:portIdx]
1711 }
1712
1713 return url
1714}