v0.1 bf7c821

webview_cgo.go RawHistoryFolder

1//go:build webkit_cgo
2
3package webkit
4
5/*
6#cgo pkg-config: webkit2gtk-4.0 gtk+-3.0 javascriptcoregtk-4.0
7#include <stdlib.h>
8#include <gtk/gtk.h>
9#include <webkit2/webkit2.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_window_new(GTK_WINDOW_TOPLEVEL); }
17static void connect_destroy_quit(GtkWidget* w) { g_signal_connect(w, "destroy", G_CALLBACK(gtk_main_quit), NULL); }
18static WebKitWebView* as_webview(GtkWidget* w) { return WEBKIT_WEB_VIEW(w); }
19static WebKitWebsiteDataManager* make_wdm(const gchar* data, const gchar* cache){
20    return webkit_website_data_manager_new("base-data-directory", data, "base-cache-directory", cache, NULL);
21}
22
23static gboolean gtk_prefers_dark() {
24    GtkSettings* settings = gtk_settings_get_default();
25    if (!settings) return FALSE;
26    gboolean prefer = FALSE;
27    g_object_get(settings, "gtk-application-prefer-dark-theme", &prefer, NULL);
28    return prefer;
29}
30
31// Note: preferred color scheme is handled via a user script injection to support
32// older WebKitGTK versions without the color-scheme API. See enableUserContentManager.
33
34// Forward declaration of Go callback
35extern void goOnKeyPress(unsigned long id, guint keyval, GdkModifierType state);
36extern void goOnButtonPress(unsigned long id, guint button, GdkModifierType state);
37extern void goOnUcmMessage(unsigned long id, const char* json);
38extern void goOnTitleChanged(unsigned long id, const char* title);
39extern void goOnURIChanged(unsigned long id, const char* uri);
40extern void* goResolveURIScheme(char* uri, size_t* out_len, char** out_mime);
41
42gboolean on_key_press(GtkWidget* widget, GdkEventKey* event, gpointer user_data) {
43    (void)widget;
44    unsigned long id = (unsigned long)user_data;
45    goOnKeyPress(id, event->keyval, event->state);
46    return FALSE; // Propagate
47}
48
49gboolean on_button_press(GtkWidget* widget, GdkEventButton* event, gpointer user_data) {
50    (void)widget;
51    unsigned long id = (unsigned long)user_data;
52    goOnButtonPress(id, event->button, event->state);
53    return FALSE;
54}
55
56void on_title_notify(GObject* obj, GParamSpec* pspec, gpointer user_data) {
57    (void)pspec;
58    WebKitWebView* view = WEBKIT_WEB_VIEW(obj);
59    const gchar* title = webkit_web_view_get_title(view);
60    GtkWindow* win = GTK_WINDOW(user_data);
61    if (title && win) {
62        gtk_window_set_title(win, title);
63    }
64}
65
66void on_title_notify_id(GObject* obj, GParamSpec* pspec, gpointer user_data) {
67    (void)pspec; (void)obj;
68    const gchar* title = webkit_web_view_get_title(WEBKIT_WEB_VIEW(obj));
69    if (title) { goOnTitleChanged((unsigned long)user_data, title); }
70}
71
72void on_uri_notify(GObject* obj, GParamSpec* pspec, gpointer user_data) {
73    (void)pspec; (void)obj;
74    const gchar* uri = webkit_web_view_get_uri(WEBKIT_WEB_VIEW(obj));
75    if (uri) { goOnURIChanged((unsigned long)user_data, uri); }
76}
77
78static gchar* js_result_to_utf8(WebKitJavascriptResult* r) {
79    JSCValue* v = webkit_javascript_result_get_js_value(r);
80    // Returns newly-allocated string; free with g_free()
81    return jsc_value_to_string(v);
82}
83
84void on_ucm_message(WebKitUserContentManager* m, WebKitJavascriptResult* r, gpointer user_data) {
85    (void)m;
86    unsigned long id = (unsigned long)user_data;
87    gchar* s = js_result_to_utf8(r);
88    goOnUcmMessage(id, s);
89    g_free(s);
90}
91
92void on_uri_scheme(WebKitURISchemeRequest* request, gpointer user_data) {
93    (void)user_data;
94    const gchar* uri = webkit_uri_scheme_request_get_uri(request);
95    size_t n = 0;
96    char* mime = NULL;
97    void* buf = goResolveURIScheme((char*)uri, &n, &mime);
98    if (buf && n > 0 && mime) {
99        GInputStream* stream = g_memory_input_stream_new_from_data(buf, (gssize)n, g_free);
100        webkit_uri_scheme_request_finish(request, stream, (gint64)n, mime);
101        g_object_unref(stream);
102        g_free(mime);
103    } else {
104        GError* err = g_error_new_literal(g_quark_from_string("dumber"), 404, "Not found");
105        webkit_uri_scheme_request_finish_error(request, err);
106        g_error_free(err);
107    }
108}
109*/
110import "C"
111
112import (
113    "errors"
114    "log"
115    "os"
116    "path/filepath"
117    "runtime"
118    "unsafe"
119)
120
121const omniboxDefaultScript = `(() => {
122  try {
123    if (window.__dumber_omnibox_loaded) return; window.__dumber_omnibox_loaded = true;
124    const H = { el:null,input:null,list:null,visible:false,suggestions:[],debounceTimer:0,
125      post(m){ try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify(m)); }catch(_){} },
126      render(){ if(!this.el) this.mount(); this.el.style.display=this.visible?'block':'none'; if(this.visible) this.input.focus(); },
127      mount(){ const r=document.createElement('div'); r.style.cssText='position:fixed;inset:0;z-index:2147483647;display:none;';
128        const b=document.createElement('div'); b.style.cssText='max-width:720px;margin:8vh auto;padding:10px 12px;background:#16181a;color:#e6e6e6;border:1px solid #2a2e33;border-radius:10px;box-shadow:0 12px 36px rgba(0,0,0,.55);backdrop-filter:saturate(120%) blur(2px);font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,\"Helvetica Neue\",Arial,sans-serif;';
129        const i=document.createElement('input'); i.type='text'; i.placeholder='Type URL or search…'; i.style.cssText='display:block;width:100%;box-sizing:border-box;padding:12px 14px;border-radius:8px;border:1px solid #3a3f45;background:#0f1113;color:#e6e6e6;font-size:15px;outline:none;';
130        const l=document.createElement('div'); l.style.cssText='margin-top:10px;max-height:52vh;overflow:auto;border-top:1px solid #2a2e33;';
131        b.appendChild(i); b.appendChild(l); r.appendChild(b); document.documentElement.appendChild(r);
132        i.addEventListener('keydown', (e)=>{ if(e.key==='Escape'){H.toggle(false);} else if(e.key==='Enter'){ const pick=H.suggestions[H.selectedIndex|0]; const v=(pick&&pick.url)||i.value||''; if(v) H.post({type:'navigate', url:v}); H.toggle(false);} else if(e.key==='ArrowDown' || e.key==='ArrowUp'){ e.preventDefault(); const n=H.suggestions.length; if(n){ H.selectedIndex=(H.selectedIndex||0)+(e.key==='ArrowDown'?1:-1); if(H.selectedIndex<0)H.selectedIndex=n-1; if(H.selectedIndex>=n)H.selectedIndex=0; H.paintList(); } } });
133        i.addEventListener('input', ()=>{ clearTimeout(H.debounceTimer); const q=i.value; H.debounceTimer=setTimeout(()=>H.post({type:'query', q, limit:10}), 120); });
134        this.el=r; this.input=i; this.list=l; this.selectedIndex=-1; this.paintList(); },
135      paintList(){ const l=this.list; if(!l) return; l.textContent=''; this.suggestions.forEach((s,i)=>{ const it=document.createElement('div'); it.style.cssText='padding:10px 12px;display:flex;gap:10px;align-items:center;cursor:pointer;border-bottom:1px solid #252a2f;'+(i===this.selectedIndex?'background:#0c0f12;':'');
136          const icon=document.createElement('img'); icon.src=s.favicon||''; icon.width=18; icon.height=18; icon.loading='lazy'; icon.style.cssText='flex:0 0 18px;width:18px;height:18px;border-radius:4px;opacity:.95;'; icon.onerror=()=>{ icon.style.display='none'; };
137          const url=document.createElement('div'); url.textContent=s.url||''; url.style.cssText='flex:1;min-width:0;color:#cfe6ff;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
138          it.appendChild(icon); it.appendChild(url);
139          it.addEventListener('mouseenter',()=>{ H.selectedIndex=i; H.paintList(); });
140          it.addEventListener('click',()=>{ H.post({type:'navigate', url:s.url}); H.toggle(false); });
141          l.appendChild(it);
142        }); },
143      toggle(v){ this.visible=(typeof v==='boolean')?v:!this.visible; this.render(); }, setSuggestions(a){ this.suggestions=Array.isArray(a)?a:[]; this.selectedIndex=-1; this.paintList(); }
144    };
145    window.addEventListener('keydown', (e)=>{ if((e.ctrlKey||e.metaKey) && (e.key==='l'||e.key==='L')){ e.preventDefault(); H.toggle(true); } }, true);
146    window.__dumber_setSuggestions = (a)=> H.setSuggestions(a);
147    window.__dumber_toggle = ()=> H.toggle();
148  } catch(_){}
149})();`
150
151type nativeView struct {
152    win  *C.GtkWidget
153    view *C.GtkWidget
154    wv   *C.WebKitWebView
155    ucm  *C.WebKitUserContentManager
156}
157
158// WebView represents a browser view powered by WebKit2GTK.
159type WebView struct {
160    config    *Config
161    zoom      float64
162    url       string
163    destroyed bool
164    native    *nativeView
165    window    *Window
166    id        uintptr
167    msgHandler func(payload string)
168    titleHandler func(title string)
169    uriHandler   func(uri string)
170}
171
172// NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
173func NewWebView(cfg *Config) (*WebView, error) {
174    log.Printf("[webkit] Initializing GTK and creating WebView (CGO)")
175    if C.gtk_init_check(nil, nil) == 0 {
176        return nil, errors.New("failed to initialize GTK")
177    }
178
179    if cfg == nil {
180        cfg = &Config{}
181    }
182
183    // Prepare persistent website data/caches
184    dataDir := cfg.DataDir
185    cacheDir := cfg.CacheDir
186    if dataDir == "" { dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data") }
187    if cacheDir == "" { cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache") }
188    _ = os.MkdirAll(dataDir, 0o755)
189    _ = os.MkdirAll(cacheDir, 0o755)
190
191    cData := C.CString(dataDir)
192    cCache := C.CString(cacheDir)
193    defer C.free(unsafe.Pointer(cData))
194    defer C.free(unsafe.Pointer(cCache))
195    wdm := C.make_wdm((*C.gchar)(cData), (*C.gchar)(cCache))
196    if wdm == nil { return nil, errors.New("failed to create WebsiteDataManager") }
197    ctx := C.webkit_web_context_new_with_website_data_manager(wdm)
198    if ctx == nil { return nil, errors.New("failed to create WebContext") }
199
200    // Register custom URI scheme handler for "dumb://"
201    {
202        sch := C.CString("dumb")
203        C.webkit_web_context_register_uri_scheme(ctx, sch, (C.WebKitURISchemeRequestCallback)(C.on_uri_scheme), nil, nil)
204        C.free(unsafe.Pointer(sch))
205    }
206
207    // Cookie manager persistent storage
208    cm := C.webkit_web_context_get_cookie_manager(ctx)
209    if cm != nil {
210        cookiePath := filepath.Join(dataDir, "cookies.sqlite")
211        cCookie := C.CString(cookiePath)
212        defer C.free(unsafe.Pointer(cCookie))
213        C.webkit_cookie_manager_set_persistent_storage(cm, (*C.gchar)(cCookie), C.WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE)
214        C.webkit_cookie_manager_set_accept_policy(cm, C.WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS)
215    }
216
217    // Create WebView with context
218    viewWidget := C.webkit_web_view_new_with_context(ctx) // GtkWidget*
219    if viewWidget == nil {
220        return nil, errors.New("failed to create WebKitWebView")
221    }
222
223    // Create a top-level window to host the view
224    win := C.new_window()
225    if win == nil {
226        return nil, errors.New("failed to create GtkWindow")
227    }
228
229    // Pack view widget into the window
230    C.gtk_container_add((*C.GtkContainer)(unsafe.Pointer(win)), viewWidget)
231    C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
232    C.connect_destroy_quit(win)
233
234    v := &WebView{
235        config: cfg,
236        zoom:   1.0,
237        native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: nil},
238        window: &Window{win: win},
239    }
240    // Allow receiving button press events
241    C.gtk_widget_add_events(win, C.GDK_BUTTON_PRESS_MASK|C.GDK_BUTTON_RELEASE_MASK)
242    C.gtk_widget_add_events(viewWidget, C.GDK_BUTTON_PRESS_MASK|C.GDK_BUTTON_RELEASE_MASK)
243
244    // Assign an ID for accelerator dispatch and connect input events
245    v.id = nextViewID()
246    registerView(v.id, v)
247    C.g_signal_connect_data(C.gpointer(unsafe.Pointer(win)), C.CString("key-press-event"), C.GCallback(C.on_key_press), C.gpointer(v.id), nil, 0)
248    C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), C.CString("button-press-event"), C.GCallback(C.on_button_press), C.gpointer(v.id), nil, 0)
249    C.g_signal_connect_data(C.gpointer(unsafe.Pointer(win)), C.CString("button-press-event"), C.GCallback(C.on_button_press), C.gpointer(v.id), nil, 0)
250    // Update window title when page title changes
251    C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), C.CString("notify::title"), C.GCallback(C.on_title_notify), C.gpointer(unsafe.Pointer(win)), nil, 0)
252    // Also dispatch title change to Go with view id
253    C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), C.CString("notify::title"), C.GCallback(C.on_title_notify_id), C.gpointer(v.id), nil, 0)
254    // Notify URI changes to Go to keep current URL in sync
255    C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), C.CString("notify::uri"), C.GCallback(C.on_uri_notify), C.gpointer(v.id), nil, 0)
256    if cfg.ZoomDefault > 0 {
257        v.zoom = cfg.ZoomDefault
258        C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom))
259    }
260    if cfg.EnableDeveloperExtras {
261        settings := C.webkit_web_view_get_settings(v.native.wv)
262        if settings != nil {
263            C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))
264        }
265    }
266    // Apply default fonts if provided in cfg
267    if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
268        if cfg.DefaultSansFont != "" {
269            csans := C.CString(cfg.DefaultSansFont)
270            C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans))
271            C.free(unsafe.Pointer(csans))
272            // Also set default font family to sans
273            csans2 := C.CString(cfg.DefaultSansFont)
274            C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2))
275            C.free(unsafe.Pointer(csans2))
276        }
277        if cfg.DefaultSerifFont != "" {
278            cserif := C.CString(cfg.DefaultSerifFont)
279            C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif))
280            C.free(unsafe.Pointer(cserif))
281        }
282        if cfg.DefaultMonospaceFont != "" {
283            cmono := C.CString(cfg.DefaultMonospaceFont)
284            C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono))
285            C.free(unsafe.Pointer(cmono))
286        }
287        if cfg.DefaultFontSize > 0 {
288            C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize))
289        }
290    }
291    if cfg.InitialURL != "" {
292        _ = v.LoadURL(cfg.InitialURL)
293    }
294    // Ensure native resources are released if user forgets to call Destroy
295    runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() })
296    log.Printf("[webkit] WebView created (CGO)")
297
298    // Obtain the view's UserContentManager and inject script
299    if v.native != nil && v.native.wv != nil {
300        u := C.webkit_web_view_get_user_content_manager(v.native.wv)
301        if u != nil {
302            v.native.ucm = u
303            v.enableUserContentManager()
304        }
305    }
306    return v, nil
307}
308
309func (w *WebView) LoadURL(rawURL string) error {
310    if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
311        return ErrNotImplemented
312    }
313    w.url = rawURL
314    curl := C.CString(rawURL)
315    defer C.free(unsafe.Pointer(curl))
316    C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl))
317    log.Printf("[webkit] LoadURL: %s", rawURL)
318    return nil
319}
320
321func (w *WebView) Show() error {
322    if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
323        return ErrNotImplemented
324    }
325    C.gtk_widget_show(w.native.win)
326    C.gtk_widget_show(w.native.view)
327    log.Printf("[webkit] Show window")
328    return nil
329}
330
331func (w *WebView) Hide() error {
332    if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
333        return ErrNotImplemented
334    }
335    C.gtk_widget_hide(w.native.win)
336    return nil
337}
338
339func (w *WebView) Destroy() error {
340    if w == nil || w.native == nil || w.native.win == nil {
341        return ErrNotImplemented
342    }
343    C.gtk_widget_destroy(w.native.win)
344    w.destroyed = true
345    unregisterView(w.id)
346    log.Printf("[webkit] Destroy window")
347    return nil
348}
349
350// Window returns the associated native window.
351func (w *WebView) Window() *Window { return w.window }
352
353// GetCurrentURL returns the current URI from WebKit.
354func (w *WebView) GetCurrentURL() string {
355    if w == nil || w.native == nil || w.native.wv == nil { return "" }
356    uri := C.webkit_web_view_get_uri(w.native.wv)
357    if uri == nil { return "" }
358    return C.GoString((*C.char)(unsafe.Pointer(uri)))
359}
360
361func (w *WebView) GoBack() error {
362    if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
363    C.webkit_web_view_go_back(w.native.wv)
364    return nil
365}
366
367func (w *WebView) GoForward() error {
368    if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
369    C.webkit_web_view_go_forward(w.native.wv)
370    return nil
371}
372
373// RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
374func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
375
376func (w *WebView) dispatchScriptMessage(payload string) {
377    if w != nil && w.msgHandler != nil { w.msgHandler(payload) }
378}
379
380// RegisterTitleChangedHandler registers a callback invoked when the page title changes.
381func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
382
383func (w *WebView) dispatchTitleChanged(title string) {
384    if w != nil && w.titleHandler != nil { w.titleHandler(title) }
385}
386
387// RegisterURIChangedHandler registers a callback invoked when the current page URI changes.
388func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }
389
390func (w *WebView) dispatchURIChanged(uri string) {
391    if w != nil && w.uriHandler != nil { w.uriHandler(uri) }
392}
393
394// enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
395func (w *WebView) enableUserContentManager() {
396    if w == nil || w.native == nil || w.native.ucm == nil { return }
397    // Register handler "dumber"
398    cname := C.CString("dumber")
399    defer C.free(unsafe.Pointer(cname))
400    C.webkit_user_content_manager_register_script_message_handler(w.native.ucm, (*C.gchar)(cname))
401    C.g_signal_connect_data(C.gpointer(unsafe.Pointer(w.native.ucm)), C.CString("script-message-received::dumber"), C.GCallback(C.on_ucm_message), C.gpointer(w.id), nil, 0)
402
403    // Inject color-scheme preference script at document-start to inform sites of system theme
404    preferDark := C.gtk_prefers_dark() != 0
405    if preferDark {
406        log.Printf("[theme] GTK prefers: dark")
407    } else {
408        log.Printf("[theme] GTK prefers: light")
409    }
410    var schemeJS string
411    if preferDark {
412        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) } })();"
413    } else {
414        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) } })();"
415    }
416    cScheme := C.CString(schemeJS)
417    defer C.free(unsafe.Pointer(cScheme))
418    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)
419    if schemeScript != nil {
420        C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript)
421        C.webkit_user_script_unref(schemeScript)
422    }
423
424    // Add user script at document-start
425    src := C.CString(omniboxDefaultScript)
426    defer C.free(unsafe.Pointer(src))
427    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)
428    if script != nil {
429        C.webkit_user_content_manager_add_script(w.native.ucm, script)
430        C.webkit_user_script_unref(script)
431        log.Printf("[webkit] UCM omnibox script injected at document-start")
432    }
433
434    // Inject Wails runtime fetch interceptor for homepage bridging
435    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(_){} })();`
436    cWails := C.CString(wailsBridge)
437    defer C.free(unsafe.Pointer(cWails))
438    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)
439    if wailsScript != nil {
440        C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript)
441        C.webkit_user_script_unref(wailsScript)
442    }
443}

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