v0.4.0 6203a22

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#include <stdlib.h>
8#include <string.h>
9#include <gtk/gtk.h>
10#include <webkit/webkit.h>
11#include <glib-object.h>
12#include <gdk/gdk.h>
13#include <jsc/jsc.h>
14#include <glib.h>
15#include <gio/gio.h>
16
17static GtkWidget* new_window() { return GTK_WIDGET(gtk_window_new()); }
18
19// Forward declaration
20extern void goQuitMainLoop();
21
22// GTK4 close-request signal handler
23static gboolean on_close_request(GtkWindow* window, gpointer user_data) {
24    (void)window; (void)user_data;
25    goQuitMainLoop();
26    return FALSE; // Allow the window to close
27}
28
29// Connect window close signal to quit main loop
30static void connect_destroy_quit(GtkWidget* w) {
31    if (!w) return;
32    g_signal_connect(G_OBJECT(w), "close-request", G_CALLBACK(on_close_request), NULL);
33}
34static WebKitWebView* as_webview(GtkWidget* w) { return WEBKIT_WEB_VIEW(w); }
35// WebsiteDataManager creation will be handled via GTK4/WebKit6 APIs in Go code.
36
37// Forward declare helpers used below
38static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy);
39
40// Construct a WebKitWebView via g_object_new with a fresh UserContentManager.
41static 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) {
42    WebKitUserContentManager* u = webkit_user_content_manager_new();
43    if (!u) return NULL;
44    // WebKitGTK 6: create NetworkSession with data/cache directories
45    WebKitNetworkSession* sess = webkit_network_session_new(
46        data_dir ? data_dir : "",
47        cache_dir ? cache_dir : "");
48    if (!sess) { g_object_unref(u); return NULL; }
49    // Apply memory pressure settings to the session when provided
50    if (pressure_settings) {
51        // WebKitGTK 6.0 API: setter expects only the settings pointer
52        webkit_network_session_set_memory_pressure_settings(pressure_settings);
53    }
54    // Configure cookie persistence and policy (no third-party by default)
55    WebKitCookieManager* cm = webkit_network_session_get_cookie_manager(sess);
56    if (cm) {
57        if (cookie_path && cookie_path[0]) {
58            webkit_cookie_manager_set_persistent_storage(cm, cookie_path, WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
59        }
60        // 2 => no-third-party (default), 0 => always, 1 => never
61        maybe_set_cookie_policy(cm, 2);
62    }
63    // Persist credentials (HTTP auth, etc.)
64    webkit_network_session_set_persistent_credential_storage_enabled(sess, TRUE);
65    GtkWidget* w = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW,
66        "user-content-manager", u,
67        "network-session", sess,
68        NULL));
69    // WebView holds refs to provided objects; drop our temporary refs
70    g_object_unref(sess);
71    if (out_ucm) { *out_ucm = u; }
72    return w;
73}
74
75static gboolean gtk_prefers_dark() {
76    // Method 1: Check GNOME desktop interface color-scheme (primary method)
77    GSettings* desktop_settings = g_settings_new("org.gnome.desktop.interface");
78    if (desktop_settings) {
79        gchar* color_scheme = g_settings_get_string(desktop_settings, "color-scheme");
80        if (color_scheme) {
81            gboolean prefer_dark = (g_strcmp0(color_scheme, "prefer-dark") == 0);
82            g_free(color_scheme);
83            g_object_unref(desktop_settings);
84            if (prefer_dark) return TRUE;
85        }
86        g_object_unref(desktop_settings);
87    }
88    
89    // Method 2: Check GTK theme name for dark variants
90    GtkSettings* settings = gtk_settings_get_default();
91    if (settings) {
92        gchar* theme_name = NULL;
93        g_object_get(settings, "gtk-theme-name", &theme_name, NULL);
94        if (theme_name) {
95            gboolean is_dark = (strstr(theme_name, "-dark") != NULL || 
96                               strstr(theme_name, "-Dark") != NULL);
97            g_free(theme_name);
98            if (is_dark) return TRUE;
99        }
100        
101        // Method 3: Check gtk-application-prefer-dark-theme (fallback)
102        gboolean prefer = FALSE;
103        g_object_get(settings, "gtk-application-prefer-dark-theme", &prefer, NULL);
104        if (prefer) return TRUE;
105    }
106    
107    return FALSE;
108}
109
110// Note: preferred color scheme is handled via a user script injection to support
111// older WebKitGTK versions without the color-scheme API. See enableUserContentManager.
112
113extern void goOnUcmMessage(unsigned long id, const char* json);
114extern void goOnTitleChanged(unsigned long id, const char* title);
115extern void goOnURIChanged(unsigned long id, const char* uri);
116extern void goOnThemeChanged(unsigned long id, int prefer_dark);
117extern void* goResolveURIScheme(char* uri, size_t* out_len, char** out_mime);
118
119void on_title_notify(GObject* obj, GParamSpec* pspec, gpointer user_data) {
120    (void)pspec;
121    WebKitWebView* view = WEBKIT_WEB_VIEW(obj);
122    const gchar* title = webkit_web_view_get_title(view);
123    GtkWindow* win = GTK_WINDOW(user_data);
124    if (title && win) {
125        gtk_window_set_title(win, title);
126    }
127}
128
129void on_title_notify_id(GObject* obj, GParamSpec* pspec, gpointer user_data) {
130    (void)pspec; (void)obj;
131    const gchar* title = webkit_web_view_get_title(WEBKIT_WEB_VIEW(obj));
132    if (title) { goOnTitleChanged((unsigned long)user_data, title); }
133}
134
135void on_uri_notify(GObject* obj, GParamSpec* pspec, gpointer user_data) {
136    (void)pspec; (void)obj;
137    const gchar* uri = webkit_web_view_get_uri(WEBKIT_WEB_VIEW(obj));
138    if (uri) { goOnURIChanged((unsigned long)user_data, uri); }
139}
140
141// React to GTK theme preference changes at runtime
142void on_theme_changed(GObject* obj, GParamSpec* pspec, gpointer user_data) {
143    (void)pspec;
144    GtkSettings* settings = GTK_SETTINGS(obj);
145    if (!settings) return;
146    gboolean prefer = FALSE;
147    g_object_get(settings, "gtk-application-prefer-dark-theme", &prefer, NULL);
148    goOnThemeChanged((unsigned long)user_data, prefer ? 1 : 0);
149}
150
151// NOTE: UCM message callback signature changed in WebKitGTK 6; will be reimplemented later.
152
153void on_uri_scheme(WebKitURISchemeRequest* request, gpointer user_data) {
154    (void)user_data;
155    const gchar* uri = webkit_uri_scheme_request_get_uri(request);
156    size_t n = 0;
157    char* mime = NULL;
158    void* buf = goResolveURIScheme((char*)uri, &n, &mime);
159    if (buf && n > 0 && mime) {
160        GInputStream* stream = g_memory_input_stream_new_from_data(buf, (gssize)n, g_free);
161        webkit_uri_scheme_request_finish(request, stream, (gint64)n, mime);
162        g_object_unref(stream);
163        g_free(mime);
164    } else {
165        GError* err = g_error_new_literal(g_quark_from_string("dumber"), 404, "Not found");
166        webkit_uri_scheme_request_finish_error(request, err);
167        g_error_free(err);
168    }
169}
170
171// Script message handler wiring for WebKitGTK 6
172// Forward declaration for callback used in g_signal_connect_data
173// WebKitGTK 6 delivers JSCValue* for script-message-received::NAME
174static void ucm_on_script_message_cb(WebKitUserContentManager* ucm, JSCValue* val, gpointer user_data);
175// Uses WebKitScriptMessage and script-message-received::NAME signal.
176static void ucm_on_script_message_cb(WebKitUserContentManager* ucm, JSCValue* val, gpointer user_data) {
177    (void)ucm;
178    if (!val) return;
179    gchar* s = jsc_value_to_string(val);
180    if (!s) return;
181    goOnUcmMessage((unsigned long)user_data, s);
182    g_free(s);
183}
184
185static gboolean register_ucm_handler(WebKitUserContentManager* ucm, const char* name, unsigned long id) {
186    if (!ucm || !name) return FALSE;
187    // WebKitGTK 6 signature: (ucm, name, world_name)
188    webkit_user_content_manager_register_script_message_handler(ucm, name, NULL);
189    gchar* signal = g_strdup_printf("script-message-received::%s", name);
190    if (!signal) return FALSE;
191    g_signal_connect_data(G_OBJECT(ucm), signal, G_CALLBACK(ucm_on_script_message_cb), (gpointer)id, NULL, 0);
192    g_free(signal);
193    return TRUE;
194}
195
196// ----- Conditional helpers for settings across WebKit versions -----
197#ifndef WEBKIT_CHECK_VERSION
198#define WEBKIT_CHECK_VERSION(major,minor,micro) (0)
199#endif
200
201static void maybe_set_hw_policy(WebKitSettings* settings, int mode) {
202#if WEBKIT_CHECK_VERSION(2,40,0)
203    // mode: 0=ON_DEMAND, 1=ALWAYS, 2=NEVER
204    if (!settings) return;
205    if (mode == 1) {
206        webkit_settings_set_hardware_acceleration_policy(settings, WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS);
207    } else if (mode == 2) {
208        webkit_settings_set_hardware_acceleration_policy(settings, WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER);
209    } else {
210        // Leave default policy (auto) without forcing a value to avoid referencing
211        // WEBKIT_HARDWARE_ACCELERATION_POLICY_ON_DEMAND on older headers.
212    }
213#else
214    (void)settings; (void)mode;
215#endif
216}
217
218static void maybe_set_draw_indicators(WebKitSettings* settings, int enable) {
219#if WEBKIT_CHECK_VERSION(2,36,0)
220    if (!settings) return;
221    webkit_settings_set_draw_compositing_indicators(settings, enable ? 1 : 0);
222#else
223    (void)settings; (void)enable;
224#endif
225}
226
227static void maybe_set_webgl(WebKitSettings* settings, int enable) {
228#if WEBKIT_CHECK_VERSION(2,20,0)
229    if (!settings) return;
230    webkit_settings_set_enable_webgl(settings, enable ? 1 : 0);
231#else
232    (void)settings; (void)enable;
233#endif
234}
235
236static void maybe_set_canvas_accel(WebKitSettings* settings, int enable) {
237#if WEBKIT_CHECK_VERSION(2,20,0)
238    if (!settings) return;
239    webkit_settings_set_enable_2d_canvas_acceleration(settings, enable ? 1 : 0);
240#else
241    (void)settings; (void)enable;
242#endif
243}
244
245static void maybe_set_media_user_gesture(WebKitSettings* settings, int require) {
246#if WEBKIT_CHECK_VERSION(2,20,0)
247    if (!settings) return;
248    webkit_settings_set_media_playback_requires_user_gesture(settings, require ? 1 : 0);
249#else
250    (void)settings; (void)require;
251#endif
252}
253
254// Enable two-finger swipe back/forward navigation gestures when supported
255static void maybe_set_back_forward_gestures(WebKitSettings* settings, int enable) {
256#if WEBKIT_CHECK_VERSION(2,38,0)
257    if (!settings) return;
258    webkit_settings_set_enable_back_forward_navigation_gestures(settings, enable ? 1 : 0);
259#else
260    (void)settings; (void)enable;
261#endif
262}
263
264// Set cookie accept policy with compile-time compatibility across WebKit versions
265static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy) {
266    if (!cm) return;
267#if defined(WEBKIT_COOKIE_ACCEPT_POLICY_ALWAYS)
268    WebKitCookieAcceptPolicy p = WEBKIT_COOKIE_ACCEPT_POLICY_ALWAYS;
269    if (policy == 1) p = WEBKIT_COOKIE_ACCEPT_POLICY_NEVER; // never
270    else if (policy == 2) p = WEBKIT_COOKIE_ACCEPT_POLICY_NO_THIRD_PARTY; // no third-party
271    webkit_cookie_manager_set_accept_policy(cm, p);
272#elif defined(WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS)
273    WebKitCookieAcceptPolicy p = WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS;
274    if (policy == 1) p = WEBKIT_COOKIE_POLICY_ACCEPT_NEVER;
275    else if (policy == 2) p = WEBKIT_COOKIE_POLICY_ACCEPT_NO_THIRD_PARTY;
276    webkit_cookie_manager_set_accept_policy(cm, p);
277#else
278    (void)policy; // Not supported in this header version
279#endif
280}
281
282// Memory pressure settings helpers (version-guarded)
283static WebKitMemoryPressureSettings* create_memory_pressure_settings(
284    unsigned int memory_limit_mb,
285    double conservative_threshold,
286    double strict_threshold,
287    double kill_threshold,
288    double poll_interval) {
289    // If headers don't expose memory pressure APIs, return NULL to signal unsupported
290#if WEBKIT_CHECK_VERSION(2,44,0)
291    WebKitMemoryPressureSettings* settings = webkit_memory_pressure_settings_new();
292    if (!settings) return NULL;
293
294    if (memory_limit_mb > 0) {
295        webkit_memory_pressure_settings_set_memory_limit(settings, (guint)(memory_limit_mb * 1024 * 1024));
296    }
297
298    webkit_memory_pressure_settings_set_conservative_threshold(settings, conservative_threshold);
299    webkit_memory_pressure_settings_set_strict_threshold(settings, strict_threshold);
300
301    if (kill_threshold > 0) {
302        webkit_memory_pressure_settings_set_kill_threshold(settings, kill_threshold);
303    }
304
305    webkit_memory_pressure_settings_set_poll_interval(settings, poll_interval);
306
307    return settings;
308#else
309    (void)memory_limit_mb; (void)conservative_threshold; (void)strict_threshold; (void)kill_threshold; (void)poll_interval;
310    return NULL;
311#endif
312}
313
314// Cache model configuration
315static void maybe_set_cache_model(WebKitWebContext* context, int model) {
316    if (!context) return;
317    WebKitCacheModel cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; // default
318
319    switch (model) {
320        case 0: cache_model = WEBKIT_CACHE_MODEL_DOCUMENT_VIEWER; break;
321        case 1: cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; break;
322        case 2:
323#ifdef WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER
324            cache_model = WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER;
325#else
326            cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER;
327#endif
328            break;
329    }
330
331    webkit_web_context_set_cache_model(context, cache_model);
332}
333
334// Additional settings for memory optimization
335static void maybe_set_page_cache(WebKitSettings* settings, int enable) {
336#if WEBKIT_CHECK_VERSION(2,6,0)
337    if (!settings) return;
338    webkit_settings_set_enable_page_cache(settings, enable ? TRUE : FALSE);
339#else
340    (void)settings; (void)enable;
341#endif
342}
343
344// Offline Web Application Cache is deprecated in modern WebKit; keep as no-op to avoid warnings.
345static void maybe_set_offline_app_cache(WebKitSettings* settings, int enable) {
346    (void)settings; (void)enable;
347}
348
349// Apply memory pressure settings to context if available
350static void maybe_set_memory_pressure_settings(WebKitWebContext* context, WebKitMemoryPressureSettings* settings) {
351    // No-op wrapper kept for compatibility; settings applied to NetworkSession instead.
352    (void)context; (void)settings;
353}
354
355// Trigger JavaScript GC if supported by headers
356static void maybe_collect_js(WebKitWebContext* context) {
357    // Disabled by default due to API availability variance across 6.0 builds.
358    (void)context;
359}
360*/
361import "C"
362
363import (
364	"errors"
365	"log"
366	"os"
367	"path/filepath"
368	"runtime"
369	"time"
370	"unsafe"
371)
372
373// Helper function to convert bool to int for C interop
374func boolToInt(b bool) int {
375	if b {
376		return 1
377	}
378	return 0
379}
380
381
382type nativeView struct {
383	win  *C.GtkWidget
384	view *C.GtkWidget
385	wv   *C.WebKitWebView
386	ucm  *C.WebKitUserContentManager
387}
388
389type memoryStats struct {
390	pageLoadCount          int
391	lastGCTime             time.Time
392	memoryPressureSettings *C.WebKitMemoryPressureSettings
393}
394
395// WebView represents a browser view powered by WebKit2GTK.
396type WebView struct {
397	config       *Config
398	zoom         float64
399	url          string
400	destroyed    bool
401	native       *nativeView
402	window       *Window
403	id           uintptr
404	msgHandler   func(payload string)
405	titleHandler func(title string)
406	uriHandler   func(uri string)
407	zoomHandler  func(level float64)
408	memStats     *memoryStats
409	gcTicker     *time.Ticker
410	gcDone       chan struct{}
411}
412
413// NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
414func NewWebView(cfg *Config) (*WebView, error) {
415	log.Printf("[webkit] Initializing GTK and creating WebView (CGO)")
416	if C.gtk_init_check() == 0 {
417		return nil, errors.New("failed to initialize GTK")
418	}
419
420	if cfg == nil {
421		cfg = &Config{}
422	}
423
424	// Prepare persistent website data/caches
425	dataDir := cfg.DataDir
426	cacheDir := cfg.CacheDir
427	if dataDir == "" {
428		dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data")
429	}
430	if cacheDir == "" {
431		cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache")
432	}
433	_ = os.MkdirAll(dataDir, 0o755)
434	_ = os.MkdirAll(cacheDir, 0o755)
435
436	cData := C.CString(dataDir)
437	cCache := C.CString(cacheDir)
438	cookieFile := filepath.Join(dataDir, "cookies.db")
439	cCookie := C.CString(cookieFile)
440	defer C.free(unsafe.Pointer(cData))
441	defer C.free(unsafe.Pointer(cCache))
442	defer C.free(unsafe.Pointer(cCookie))
443	// Use default WebContext to register the custom URI scheme handler; the
444	// WebView itself is created with a WebKitNetworkSession for persistence.
445	ctx := C.webkit_web_context_get_default()
446
447	// Apply cache model to WebContext
448	// Default to WEBKIT_CACHE_MODEL_WEB_BROWSER to keep caching enabled
449	cacheModel := 1
450	if cfg.Memory.CacheModel != "" {
451		switch cfg.Memory.CacheModel {
452		case CacheModelWebBrowser:
453			cacheModel = 1
454		case CacheModelPrimaryWebBrowser:
455			cacheModel = 2
456		default: // CacheModelDocumentViewer or unset
457			cacheModel = 0
458		}
459	}
460	C.maybe_set_cache_model(ctx, C.int(cacheModel))
461
462	// Configure memory pressure settings if specified
463	var pressureSettings *C.WebKitMemoryPressureSettings
464	// Only configure memory pressure if explicitly requested via config
465	if cfg.Memory.MemoryLimitMB > 0 || cfg.Memory.ConservativeThreshold > 0 || cfg.Memory.StrictThreshold > 0 || cfg.Memory.KillThreshold > 0 || cfg.Memory.PollIntervalSeconds > 0 {
466		memLimitMB := cfg.Memory.MemoryLimitMB
467		conservativeThreshold := cfg.Memory.ConservativeThreshold
468		strictThreshold := cfg.Memory.StrictThreshold
469		killThreshold := cfg.Memory.KillThreshold
470		pollInterval := cfg.Memory.PollIntervalSeconds
471
472		pressureSettings = C.create_memory_pressure_settings(
473			C.uint(memLimitMB),
474			C.double(conservativeThreshold),
475			C.double(strictThreshold),
476			C.double(killThreshold),
477			C.double(pollInterval))
478
479		if pressureSettings != nil {
480			C.maybe_set_memory_pressure_settings(ctx, pressureSettings)
481			if cfg.Memory.EnableMemoryMonitoring {
482				log.Printf("[webkit] Memory pressure settings applied: limit=%dMB, conservative=%.2f, strict=%.2f, poll=%.1fs",
483					memLimitMB, conservativeThreshold, strictThreshold, pollInterval)
484			}
485		}
486	}
487
488	// Register custom URI scheme handler for "dumb://"
489	{
490		sch := C.CString("dumb")
491		C.webkit_web_context_register_uri_scheme(ctx, sch, (C.WebKitURISchemeRequestCallback)(C.on_uri_scheme), nil, nil)
492		C.free(unsafe.Pointer(sch))
493	}
494	// Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session
495
496	// Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style)
497	var createdUcm *C.WebKitUserContentManager
498	viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm, pressureSettings)
499	if viewWidget == nil {
500		return nil, errors.New("failed to create WebKitWebView")
501	}
502
503	// Create a top-level window to host the view
504	win := C.new_window()
505	if win == nil {
506		return nil, errors.New("failed to create GtkWindow")
507	}
508
509	// Pack view widget into the window
510	// GTK4: containers removed; use gtk_window_set_child
511	C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget)
512	C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
513	C.connect_destroy_quit(win)
514
515	v := &WebView{
516		config: cfg,
517		zoom:   1.0,
518		native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
519		window: &Window{win: win},
520		memStats: &memoryStats{
521			pageLoadCount:          0,
522			lastGCTime:             time.Now(),
523			memoryPressureSettings: pressureSettings,
524		},
525		gcDone: make(chan struct{}),
526	}
527	// Assign an ID for accelerator dispatch
528	v.id = nextViewID()
529	registerView(v.id, v)
530
531	// Register with global memory manager if monitoring is enabled
532	if cfg.Memory.EnableMemoryMonitoring {
533		if globalMemoryManager == nil {
534			InitializeGlobalMemoryManager(
535				cfg.Memory.EnableMemoryMonitoring,
536				cfg.Memory.ProcessRecycleThreshold,
537				30*time.Second, // Default monitoring interval
538			)
539		}
540		if globalMemoryManager != nil {
541			globalMemoryManager.RegisterWebView(v)
542		}
543	}
544	// Attach GTK4 input controllers (keyboard, mouse)
545	AttachKeyboardControllers(v)
546
547	// Watch for GTK theme changes and propagate to page at runtime
548	if settings := C.gtk_settings_get_default(); settings != nil {
549		cprop := C.CString("notify::gtk-application-prefer-dark-theme")
550		C.g_signal_connect_data(C.gpointer(unsafe.Pointer(settings)), cprop, C.GCallback(C.on_theme_changed), C.gpointer(v.id), nil, 0)
551		C.free(unsafe.Pointer(cprop))
552	}
553
554	// Native zoom shortcuts (independent of app services)
555	// Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset
556	_ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() {
557		nz := v.zoom
558		if nz <= 0 {
559			nz = 1.0
560		}
561		nz *= 1.1
562		if nz < 0.25 {
563			nz = 0.25
564		}
565		if nz > 5.0 {
566			nz = 5.0
567		}
568		_ = v.SetZoom(nz)
569	})
570	_ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() {
571		nz := v.zoom
572		if nz <= 0 {
573			nz = 1.0
574		}
575		nz *= 1.1
576		if nz < 0.25 {
577			nz = 0.25
578		}
579		if nz > 5.0 {
580			nz = 5.0
581		}
582		_ = v.SetZoom(nz)
583	})
584	_ = v.RegisterKeyboardShortcut("cmdorctrl-", func() {
585		nz := v.zoom
586		if nz <= 0 {
587			nz = 1.0
588		}
589		nz /= 1.1
590		if nz < 0.25 {
591			nz = 0.25
592		}
593		if nz > 5.0 {
594			nz = 5.0
595		}
596		_ = v.SetZoom(nz)
597	})
598	_ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) })
599	// Find in page (Ctrl/Cmd+F) reuses omnibox component in find mode
600	_ = v.RegisterKeyboardShortcut("cmdorctrl+f", func() { _ = v.OpenFind("") })
601	// Navigation with Alt+Arrow keys
602	_ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() { _ = v.GoBack() })
603	_ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() { _ = v.GoForward() })
604	// Update window title when page title changes
605	cNotifyTitle1 := C.CString("notify::title")
606	C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle1, C.GCallback(C.on_title_notify), C.gpointer(unsafe.Pointer(win)), nil, 0)
607	C.free(unsafe.Pointer(cNotifyTitle1))
608	// Also dispatch title change to Go with view id
609	cNotifyTitle2 := C.CString("notify::title")
610	C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle2, C.GCallback(C.on_title_notify_id), C.gpointer(v.id), nil, 0)
611	C.free(unsafe.Pointer(cNotifyTitle2))
612	// Notify URI changes to Go to keep current URL in sync
613	cNotifyURI := C.CString("notify::uri")
614	C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyURI, C.GCallback(C.on_uri_notify), C.gpointer(v.id), nil, 0)
615	C.free(unsafe.Pointer(cNotifyURI))
616	// Apply hardware acceleration and related settings based on cfg.Rendering
617	if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
618		// Hardware acceleration policy (guarded by version)
619		switch cfg.Rendering.Mode {
620		case "gpu":
621			C.maybe_set_hw_policy(settings, 1)
622			C.maybe_set_webgl(settings, 1)
623			C.maybe_set_canvas_accel(settings, 1)
624		case "cpu":
625			C.maybe_set_hw_policy(settings, 2)
626			C.maybe_set_webgl(settings, 0)
627			C.maybe_set_canvas_accel(settings, 0)
628		default: // auto
629			C.maybe_set_hw_policy(settings, 0)
630			C.maybe_set_webgl(settings, 1)
631			C.maybe_set_canvas_accel(settings, 1)
632		}
633		// Optional compositing indicators for debugging (guarded by version)
634		if cfg.Rendering.DebugGPU {
635			C.maybe_set_draw_indicators(settings, 1)
636		}
637		// Reduce media pipeline churn by requiring a user gesture for playback
638		C.maybe_set_media_user_gesture(settings, 1)
639		// Enable trackpad back/forward gestures when available
640		C.maybe_set_back_forward_gestures(settings, 1)
641
642		// Keep page cache enabled by default for performance
643		enablePageCache := true
644		if cfg.Memory.EnablePageCache {
645			enablePageCache = true
646		}
647		C.maybe_set_page_cache(settings, C.int(boolToInt(enablePageCache)))
648
649		// Offline app cache is deprecated; keep as no-op
650		C.maybe_set_offline_app_cache(settings, C.int(0))
651
652		if cfg.Memory.EnableMemoryMonitoring {
653			log.Printf("[webkit] Memory settings applied: page_cache=%v", enablePageCache)
654		}
655	}
656
657	if cfg.ZoomDefault > 0 {
658		v.zoom = cfg.ZoomDefault
659		C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom))
660	}
661	if cfg.EnableDeveloperExtras {
662		settings := C.webkit_web_view_get_settings(v.native.wv)
663		if settings != nil {
664			C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))
665		}
666	}
667	// Apply default fonts if provided in cfg
668	if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
669		if cfg.DefaultSansFont != "" {
670			csans := C.CString(cfg.DefaultSansFont)
671			C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans))
672			C.free(unsafe.Pointer(csans))
673			// Also set default font family to sans
674			csans2 := C.CString(cfg.DefaultSansFont)
675			C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2))
676			C.free(unsafe.Pointer(csans2))
677		}
678		if cfg.DefaultSerifFont != "" {
679			cserif := C.CString(cfg.DefaultSerifFont)
680			C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif))
681			C.free(unsafe.Pointer(cserif))
682		}
683		if cfg.DefaultMonospaceFont != "" {
684			cmono := C.CString(cfg.DefaultMonospaceFont)
685			C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono))
686			C.free(unsafe.Pointer(cmono))
687		}
688		if cfg.DefaultFontSize > 0 {
689			C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize))
690		}
691	}
692	if cfg.InitialURL != "" {
693		_ = v.LoadURL(cfg.InitialURL)
694	}
695	// Ensure native resources are released if user forgets to call Destroy
696	runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() })
697	log.Printf("[webkit] WebView created (CGO)")
698
699	// Initialize UCM scripts and handlers
700	if v.native != nil && v.native.ucm != nil {
701		v.enableUserContentManager()
702	}
703
704	// Start periodic JavaScript garbage collection if configured
705	if cfg.Memory.EnableGCInterval > 0 {
706		v.startPeriodicGC(time.Duration(cfg.Memory.EnableGCInterval) * time.Second)
707		if cfg.Memory.EnableMemoryMonitoring {
708			log.Printf("[webkit] Periodic GC enabled: interval=%ds", cfg.Memory.EnableGCInterval)
709		}
710	}
711
712	return v, nil
713}
714
715func (w *WebView) LoadURL(rawURL string) error {
716	if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
717		return ErrNotImplemented
718	}
719
720	// Track page loads for process recycling
721	if w.memStats != nil {
722		w.memStats.pageLoadCount++
723
724		// Check if we need to recycle this WebView
725		if w.config.Memory.ProcessRecycleThreshold > 0 &&
726			w.memStats.pageLoadCount >= w.config.Memory.ProcessRecycleThreshold {
727			if w.config.Memory.EnableMemoryMonitoring {
728				log.Printf("[webkit] Process recycle threshold reached (%d pages), consider creating new WebView",
729					w.memStats.pageLoadCount)
730			}
731		}
732	}
733
734	w.url = rawURL
735	curl := C.CString(rawURL)
736	defer C.free(unsafe.Pointer(curl))
737	C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl))
738	log.Printf("[webkit] LoadURL: %s", rawURL)
739	return nil
740}
741
742func (w *WebView) Show() error {
743	if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
744		return ErrNotImplemented
745	}
746	C.gtk_widget_set_visible(w.native.view, C.gboolean(1))
747	C.gtk_widget_set_visible(w.native.win, C.gboolean(1))
748	C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
749	log.Printf("[webkit] Show window")
750	return nil
751}
752
753func (w *WebView) Hide() error {
754	if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
755		return ErrNotImplemented
756	}
757	C.gtk_widget_set_visible(w.native.view, C.gboolean(0))
758	C.gtk_widget_set_visible(w.native.win, C.gboolean(0))
759	return nil
760}
761
762func (w *WebView) Destroy() error {
763	if w == nil || w.native == nil || w.native.win == nil {
764		return ErrNotImplemented
765	}
766
767	// Stop periodic GC if running
768	w.stopPeriodicGC()
769
770	// Clean up memory pressure settings
771	if w.memStats != nil && w.memStats.memoryPressureSettings != nil {
772		// WebKit will clean up the memory pressure settings when context is destroyed
773		w.memStats.memoryPressureSettings = nil
774	}
775
776	// Unregister from memory manager
777	if globalMemoryManager != nil {
778		globalMemoryManager.UnregisterWebView(w.id)
779	}
780
781	// GTK4: destroy windows via gtk_window_destroy
782	C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
783	w.destroyed = true
784	unregisterView(w.id)
785	log.Printf("[webkit] Destroy window")
786	return nil
787}
788
789// Window returns the associated native window.
790func (w *WebView) Window() *Window { return w.window }
791
792// GetCurrentURL returns the current URI from WebKit.
793func (w *WebView) GetCurrentURL() string {
794	if w == nil || w.native == nil || w.native.wv == nil {
795		return ""
796	}
797	uri := C.webkit_web_view_get_uri(w.native.wv)
798	if uri == nil {
799		return ""
800	}
801	return C.GoString((*C.char)(unsafe.Pointer(uri)))
802}
803
804func (w *WebView) GoBack() error {
805	if w == nil || w.native == nil || w.native.wv == nil {
806		return ErrNotImplemented
807	}
808	C.webkit_web_view_go_back(w.native.wv)
809	return nil
810}
811
812func (w *WebView) GoForward() error {
813	if w == nil || w.native == nil || w.native.wv == nil {
814		return ErrNotImplemented
815	}
816	C.webkit_web_view_go_forward(w.native.wv)
817	return nil
818}
819
820// RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
821func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
822
823func (w *WebView) dispatchScriptMessage(payload string) {
824	if w != nil && w.msgHandler != nil {
825		w.msgHandler(payload)
826	}
827}
828
829// RegisterTitleChangedHandler registers a callback invoked when the page title changes.
830func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
831
832func (w *WebView) dispatchTitleChanged(title string) {
833	if w != nil && w.titleHandler != nil {
834		w.titleHandler(title)
835	}
836}
837
838// RegisterURIChangedHandler registers a callback invoked when the current page URI changes.
839func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }
840
841func (w *WebView) dispatchURIChanged(uri string) {
842	if w != nil && w.uriHandler != nil {
843		w.uriHandler(uri)
844	}
845}
846
847// RegisterZoomChangedHandler registers a callback invoked when zoom level changes.
848func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb }
849
850func (w *WebView) dispatchZoomChanged(level float64) {
851	if w != nil && w.zoomHandler != nil {
852		w.zoomHandler(level)
853	}
854}
855
856// enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
857func (w *WebView) enableUserContentManager() {
858	if w == nil || w.native == nil || w.native.ucm == nil {
859		return
860	}
861	// Register handler "dumber"
862	cname := C.CString("dumber")
863	ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id))
864	if ok == 0 {
865		log.Printf("[webkit] Failed to register UCM handler; fallback bridge will handle messages")
866	}
867	C.free(unsafe.Pointer(cname))
868
869	// Inject color-scheme preference script at document-start to inform sites of system theme
870	preferDark := C.gtk_prefers_dark() != 0
871	if preferDark {
872		log.Printf("[theme] GTK prefers: dark")
873	} else {
874		log.Printf("[theme] GTK prefers: light")
875	}
876	var schemeJS string
877	if preferDark {
878		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) } })();"
879	} else {
880		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) } })();"
881	}
882	cScheme := C.CString(schemeJS)
883	defer C.free(unsafe.Pointer(cScheme))
884	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)
885	if schemeScript != nil {
886		C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript)
887		C.webkit_user_script_unref(schemeScript)
888	}
889
890    // Add user script at document-start (omnibox/find reusable component)
891    src := C.CString(getOmniboxScript())
892	defer C.free(unsafe.Pointer(src))
893	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)
894	if script != nil {
895		C.webkit_user_content_manager_add_script(w.native.ucm, script)
896		C.webkit_user_script_unref(script)
897		log.Printf("[webkit] UCM omnibox script injected at document-start")
898	}
899
900	// Add user script at document-start (toast notification system)
901	toastSrc := C.CString(getToastScript())
902	defer C.free(unsafe.Pointer(toastSrc))
903	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)
904	if toastScript != nil {
905		C.webkit_user_content_manager_add_script(w.native.ucm, toastScript)
906		C.webkit_user_script_unref(toastScript)
907		log.Printf("[webkit] UCM toast script injected at document-start")
908	}
909
910	// Inject Wails runtime fetch interceptor for homepage bridging
911	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(_){} })();`
912	cWails := C.CString(wailsBridge)
913	defer C.free(unsafe.Pointer(cWails))
914	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)
915	if wailsScript != nil {
916		C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript)
917		C.webkit_user_script_unref(wailsScript)
918	}
919
920	// No JS fallback bridge — native UCM is active
921}
922
923// startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically
924func (w *WebView) startPeriodicGC(interval time.Duration) {
925	if w == nil || w.gcTicker != nil {
926		return // Already running or invalid
927	}
928
929	w.gcTicker = time.NewTicker(interval)
930
931	go func() {
932		defer func() {
933			if w.gcTicker != nil {
934				w.gcTicker.Stop()
935				w.gcTicker = nil
936			}
937		}()
938
939		for {
940			select {
941			case <-w.gcTicker.C:
942				w.triggerGC()
943			case <-w.gcDone:
944				return
945			}
946		}
947	}()
948}
949
950// stopPeriodicGC stops the periodic garbage collection
951func (w *WebView) stopPeriodicGC() {
952	if w == nil {
953		return
954	}
955
956	if w.gcDone != nil {
957		close(w.gcDone)
958		w.gcDone = nil
959	}
960
961	if w.gcTicker != nil {
962		w.gcTicker.Stop()
963		w.gcTicker = nil
964	}
965}
966
967// triggerGC manually triggers JavaScript garbage collection
968func (w *WebView) triggerGC() {
969	if w == nil || w.memStats == nil {
970		return
971	}
972
973	// Get default WebContext and trigger GC
974	ctx := C.webkit_web_context_get_default()
975	if ctx != nil {
976		C.maybe_collect_js(ctx)
977		w.memStats.lastGCTime = time.Now()
978
979		if w.config.Memory.EnableMemoryMonitoring {
980			log.Printf("[webkit] JavaScript GC triggered (page loads: %d)", w.memStats.pageLoadCount)
981		}
982	}
983}
984
985// GetMemoryStats returns current memory statistics for this WebView
986func (w *WebView) GetMemoryStats() map[string]interface{} {
987	if w == nil || w.memStats == nil {
988		return nil
989	}
990
991	return map[string]interface{}{
992		"page_load_count":              w.memStats.pageLoadCount,
993		"last_gc_time":                 w.memStats.lastGCTime,
994		"has_memory_pressure_settings": w.memStats.memoryPressureSettings != nil,
995	}
996}
997
998// TriggerMemoryCleanup manually triggers garbage collection and memory cleanup
999func (w *WebView) TriggerMemoryCleanup() {
1000	if w == nil {
1001		return
1002	}
1003
1004	w.triggerGC()
1005
1006	if w.config.Memory.EnableMemoryMonitoring {
1007		log.Printf("[webkit] Manual memory cleanup triggered")
1008	}
1009}

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