v0.3.0 d226234

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

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