v0.12.0 d035193

webview.go RawHistoryFolder

1package browser
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"log"
8	"os"
9	"path/filepath"
10	"time"
11
12	"github.com/bnema/dumber/internal/app/constants"
13	"github.com/bnema/dumber/internal/app/control"
14	"github.com/bnema/dumber/internal/app/messaging"
15	"github.com/bnema/dumber/internal/config"
16	"github.com/bnema/dumber/internal/filtering"
17	"github.com/bnema/dumber/pkg/webkit"
18)
19
20// Note: Memory management and color palettes are now handled at the application level
21// The gotk4 webkit.Config only contains basic WebKit settings
22
23func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
24	// Ensure data directories exist for WebKit
25	dataDir, err := config.GetDataDir()
26	if err != nil {
27		return nil, err
28	}
29	stateDir, err := config.GetStateDir()
30	if err != nil {
31		return nil, err
32	}
33
34	webkitData := filepath.Join(dataDir, "webkit")
35	webkitCache := filepath.Join(stateDir, "webkit-cache")
36	if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
37		return nil, err
38	}
39	if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
40		return nil, err
41	}
42
43	// Build WebKit configuration with data directories for persistence
44	cfg := &webkit.Config{
45		UserAgent:            buildUserAgent(app.config),
46		EnableJavaScript:     true,
47		EnableWebGL:          true,
48		EnableMediaStream:    true,
49		HardwareAcceleration: true,
50		DefaultFontSize:      app.config.Appearance.DefaultFontSize,
51		MinimumFontSize:      8,
52		DataDir:              webkitData,
53		CacheDir:             webkitCache,
54		AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
55		CreateWindow:         true, // Default to creating a window for standalone WebViews
56	}
57
58	return cfg, nil
59}
60
61// buildUserAgent constructs the user agent string from config
62func buildUserAgent(cfg *config.Config) string {
63	if cfg.CodecPreferences.CustomUserAgent != "" {
64		return cfg.CodecPreferences.CustomUserAgent
65	}
66	// Use default WebKit user agent
67	return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
68}
69
70// buildAppearanceConfigJSON builds the palette configuration as JSON
71// The GUI expects window.__dumber_palette with just the palette data
72func (app *BrowserApp) buildAppearanceConfigJSON() string {
73	if app.config == nil {
74		return ""
75	}
76
77	// Build palette config in the format main-world.ts expects
78	// It looks for window.__dumber_palette = { "light": {...}, "dark": {...} }
79	paletteConfig := map[string]interface{}{
80		"light": map[string]string{
81			"background":      app.config.Appearance.LightPalette.Background,
82			"surface":         app.config.Appearance.LightPalette.Surface,
83			"surface_variant": app.config.Appearance.LightPalette.SurfaceVariant,
84			"text":            app.config.Appearance.LightPalette.Text,
85			"muted":           app.config.Appearance.LightPalette.Muted,
86			"accent":          app.config.Appearance.LightPalette.Accent,
87			"border":          app.config.Appearance.LightPalette.Border,
88		},
89		"dark": map[string]string{
90			"background":      app.config.Appearance.DarkPalette.Background,
91			"surface":         app.config.Appearance.DarkPalette.Surface,
92			"surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant,
93			"text":            app.config.Appearance.DarkPalette.Text,
94			"muted":           app.config.Appearance.DarkPalette.Muted,
95			"accent":          app.config.Appearance.DarkPalette.Accent,
96			"border":          app.config.Appearance.DarkPalette.Border,
97		},
98	}
99
100	payload, err := json.Marshal(paletteConfig)
101	if err != nil {
102		log.Printf("[webview] Failed to marshal palette config: %v", err)
103		return ""
104	}
105
106	return string(payload)
107}
108
109func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
110	pane := &BrowserPane{
111		webView: view,
112	}
113
114	pane.clipboardController = control.NewClipboardController(view)
115	pane.zoomController = control.NewZoomController(app.browserService, view)
116	pane.navigationController = control.NewNavigationController(
117		app.parserService,
118		app.browserService,
119		view,
120		pane.zoomController,
121	)
122	pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
123	pane.messageHandler.SetWebView(view)
124	pane.messageHandler.SetNavigationController(pane.navigationController)
125	pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
126
127	pane.zoomController.RegisterHandlers()
128	pane.shortcutHandler.RegisterShortcuts()
129
130	// Register navigation handler for workspace focus events if workspace exists
131	if app.workspace != nil {
132		app.workspace.RegisterNavigationHandler(view)
133	}
134
135	// Register favicon handlers for this webview (needed for each pane)
136	// This ensures favicons are detected and cached for all panes, not just the main webview
137	view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) {
138		// Call the private handler through a public wrapper or directly via browserService
139		// We can't call handleFaviconURIChanged directly as it's private, so we need to expose it
140		// For now, duplicate the minimal logic here
141		app.browserService.ProcessFaviconURI(pageURI, faviconURI)
142	})
143
144	return pane, nil
145}
146
147func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
148	pane, err := app.buildPane(view)
149	if err != nil {
150		return nil, err
151	}
152
153	// Generate unique pane ID
154	pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
155	pane.initializeGUITracking()
156
157	// GUI manager will be loaded on-demand when needed
158	log.Printf("[webview] Created pane for webview: %d", view.ID())
159
160	app.attachPaneHandlers(pane)
161	return pane, nil
162}
163
164func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
165	if pane == nil || pane.webView == nil {
166		return
167	}
168
169	pane.webView.RegisterTitleChangedHandler(func(title string) {
170		if title == "" {
171			return
172		}
173		url := pane.webView.GetCurrentURL()
174		if url == "" {
175			return
176		}
177
178		// Update database with new title
179		go func(url, title string) {
180			ctx := context.Background()
181			if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
182				log.Printf("Warning: failed to update page title: %v", err)
183			}
184		}(url, title)
185
186		// Update stacked pane title bar if this pane is in a stack
187		if app.workspace != nil {
188			app.workspace.UpdateTitleBar(pane.webView, title)
189		}
190	})
191
192	pane.webView.RegisterScriptMessageHandler(func(payload string) {
193		if app.workspace != nil && shouldFocusForScriptMessage(payload) {
194			app.workspace.focusByView(pane.webView)
195		}
196		pane.messageHandler.Handle(payload)
197	})
198
199	if pane.messageHandler != nil && pane.navigationController != nil {
200		pane.messageHandler.SetNavigationController(pane.navigationController)
201	}
202
203	// Setup WebKit-native popup lifecycle using create/ready-to-show/close signals
204	if app.workspace != nil {
205		node := app.workspace.GetNodeForWebView(pane.webView)
206		if node != nil {
207			app.workspace.setupPopupHandling(pane.webView, node)
208			log.Printf("[webview] Setup native popup handling for WebView ID: %d", pane.webView.ID())
209		}
210	}
211}
212
213// shouldFocusForScriptMessage filters script messages and only allows focus handoff
214// for explicit user-driven actions (navigation, history commands, popup lifecycle).
215// Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are
216// ignored so they don't steal focus from the user's active pane.
217func shouldFocusForScriptMessage(payload string) bool {
218	var msg messaging.Message
219	if err := json.Unmarshal([]byte(payload), &msg); err != nil {
220		return true // keep legacy behaviour if parsing fails
221	}
222
223	switch msg.Type {
224	case "navigate", "query", "wails", "history_recent", "history_stats", "history_search", "history_delete", "close-popup":
225		return true
226	default:
227		return false
228	}
229}
230
231// createWebView creates and configures the WebView
232func (app *BrowserApp) createWebView() error {
233	log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
234
235	cfg, err := app.buildWebkitConfig()
236	if err != nil {
237		return err
238	}
239
240	// Note: Window creation is now handled automatically by webkit.NewWebView
241	view, err := webkit.NewWebView(cfg)
242
243	if err != nil {
244		return err
245	}
246
247	pane, err := app.createPaneForView(view)
248	if err != nil {
249		return err
250	}
251
252	app.webView = view
253	app.zoomController = pane.zoomController
254	app.navigationController = pane.navigationController
255	app.clipboardController = pane.clipboardController
256	app.messageHandler = pane.messageHandler
257	app.shortcutHandler = pane.shortcutHandler
258	app.panes = []*BrowserPane{pane}
259	app.activePane = pane
260
261	if app.workspace == nil {
262		app.workspace = NewWorkspaceManager(app, pane)
263		// CRITICAL: Register navigation handler for the root webview
264		// This is needed because buildPane() couldn't register it when workspace was nil
265		app.workspace.RegisterNavigationHandler(view)
266
267		// CRITICAL: Setup popup handling for the root webview
268		// This must be done after workspace is created so WebKit's create signal is connected
269		node := app.workspace.GetNodeForWebView(view)
270		if node != nil {
271			app.workspace.setupPopupHandling(view, node)
272			log.Printf("[webview] Setup native popup handling for root WebView ID: %d", view.ID())
273		} else {
274			log.Printf("[webview] WARNING: Could not find node for root WebView ID: %d", view.ID())
275		}
276	}
277
278	// Initialize window-level global shortcuts AFTER workspace is set up
279	if window := view.Window(); window != nil {
280		app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
281		if app.windowShortcutHandler != nil {
282			log.Printf("Window-level global shortcuts initialized")
283		} else {
284			log.Printf("Warning: failed to initialize window-level shortcuts")
285		}
286	}
287
288	app.setupWebViewIntegration()
289
290	// Apply initial zoom and show window
291	app.zoomController.ApplyInitialZoom()
292
293	log.Printf("Showing WebView window…")
294	if err := view.Show(); err != nil {
295		log.Printf("Warning: failed to show WebView: %v", err)
296	} else if !webkit.IsNativeAvailable() {
297		log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
298	}
299
300	return nil
301}
302
303// setupWebViewIntegration connects the WebView to browser services
304func (app *BrowserApp) setupWebViewIntegration() {
305	app.browserService.AttachWebView(app.webView)
306
307	// GUI bundle is loaded via WebKit user scripts in SetupUserContentManager
308	// Appearance config is also injected at document-start via UserContentManager
309
310	// Use native window as title updater
311	if win := app.webView.Window(); win != nil {
312		app.browserService.SetWindowTitleUpdater(win)
313	}
314}
315
316// setupContentBlocking initializes the content blocking system with proper timing
317func (app *BrowserApp) setupContentBlocking() error {
318	log.Printf("Initializing content blocking system...")
319
320	// Enable WebKit debug logging if requested
321	webkit.SetupWebKitDebugLogging()
322
323	defer func() {
324		if r := recover(); r != nil {
325			log.Printf("Content blocking setup panic recovered: %v", r)
326		}
327	}()
328
329	// Setup filter system
330	filterManager, err := filtering.SetupFilterSystem()
331	if err != nil {
332		log.Printf("Warning: Failed to setup filter system: %v", err)
333		return nil // Don't fail browser startup, continue without filters
334	}
335
336	// Store reference to filter manager
337	app.filterManager = filterManager
338
339	// Set up callback to apply network filters when they become ready
340	// This callback will be called from the async filter loading process
341	filterManager.SetFiltersReadyCallback(func() {
342		log.Printf("[filtering] Filters ready, applying to WebView...")
343
344		// Get the WebKit JSON rules from filter manager
345		filterJSON, err := filterManager.GetNetworkFilters()
346		if err != nil {
347			log.Printf("[filtering] Failed to get network filters: %v", err)
348			return
349		}
350
351		if len(filterJSON) == 0 {
352			log.Printf("[filtering] No network filters to apply")
353			return
354		}
355
356		log.Printf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON))
357
358		// Apply filters to the WebView
359		// This must be done on the main thread since it touches GTK/WebKit
360		if app.webView != nil {
361			app.webView.RunOnMainThread(func() {
362				if err := app.webView.InitializeContentBlocking(filterJSON); err != nil {
363					log.Printf("[filtering] Failed to apply content filters: %v", err)
364				} else {
365					log.Printf("[filtering] ✅ Content blocking enabled successfully")
366				}
367			})
368		}
369	})
370
371	// Start async filter loading
372	// This allows filters to compile in the background while the browser starts
373	go func() {
374		if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
375			log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
376		}
377	}()
378
379	log.Printf("Content blocking system initialization started")
380	return nil
381}

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