v0.7.0 31c7fae

webview_cgo.go RawHistoryFolder

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", &not_valid_before,
490                 "not-valid-after", &not_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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close